feat: show AI agents on the canvas and to collaborators (#803)

* feat(canvas): draw agents' cursors as outlined sparkles

Editor state's remoteCursors becomes presenceCursors with a kind, since the list now includes local agents. People keep the filled arrow; an agent is a sparkle outlined in its owner's color, with an outlined name pill, so whose agent it is reads from the outline. Cursor drawing moves out of the pen overlay into canvas/overlays/presence.ts.

* feat(app): publish AI agent presence to collaborators

The built-in chat now appears as an agent with a callsign while it replies, at the nodes its tools touch on the run's page, and goes idle (off the canvas) when the reply ends. Agents live in a per-document presence registry and are published in their owner's awareness state, so collaborators see each other's agents in the owner's color; the payload is metadata only.

Peer awareness was cast without checks. It is now validated with Valibot, invalid fields are dropped rather than the peer, and names, selections, and agent counts are bounded.

* docs(collaboration): list the agent model among shared presence

* test(vue): a canvas story for presence cursors

Storybook now serves CanvasKit, so a story can render the real canvas:
people's arrows and agents' outlined sparkles, with controls for names,
colors, and zoom.

* feat(canvas): mark agents with a sparkle label instead of a sparkle cursor

A sparkle on its own did not read as a pointer. Agents now point with
the same filled arrow as people, in their owner's color, and their
outlined label starts with a sparkle.
This commit is contained in:
Danila Poyarkov 2026-10-03 14:01:36 +04:00 committed by GitHub
parent 0ff6b414e3
commit 749d7baec9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 844 additions and 197 deletions

View file

@ -4,6 +4,7 @@
### Breaking changes
- The editor state's `remoteCursors` is now `presenceCursors`, typed `PresenceCursor[]` from `@open-pencil/core/canvas`, and each cursor has a `kind` of `'person'` or `'agent'`.
- `VariableBinding` and the `colorVariableBinding` paint field are gone from `@open-pencil/core`, `@open-pencil/core/kiwi`, and the Kiwi `Paint` type. `fig.kiwi` never defined the field, so only `.fig` files OpenPencil itself wrote before `colorVar` contain one; reopening such a file leaves the paint's colour unbound, and binding it again records it the way Figma does.
- `encodeNodeChangeWithVariables`, `encodePaintWithVariableBinding`, and `encodeVarint` are removed from `@open-pencil/core` and `@open-pencil/core/kiwi`. They spliced a colour-variable binding into encoded bytes because the field had no schema entry; exports now write `colorVar`, which `fig.kiwi` defines, so nothing needs them. `parseVariableId` is unchanged.
- The desktop app now requires macOS 13 or later; the web app supports Chrome 111, Edge 111, Firefox 128, and Safari 16.4 or later.
@ -14,6 +15,7 @@
### Added
- See where the built-in AI chat is working: while it replies, a cursor whose outlined label shows a sparkle and a callsign such as *Fern* marks the layers it edits. In a shared room, collaborators see each other's agents in the color of the person running them.
- Jump between pages from the command palette: it lists the pages you visited recently in the tab, **Go to page…** lists every page, and typing a page name finds it.
- Choose how much an AI model thinks for each message from the chat composer, from Off to Extra high or the provider default. Anthropic, Google, DeepSeek, OpenAI, OpenRouter, and compatible models apply it, where reasoning effort previously reached only OpenAI and OpenRouter models. A model profile's thinking level, which replaces its free-text reasoning effort, sets the starting choice, and finished reasoning shows how long the model thought.
- Preview designs progressively on the canvas as direct AI providers stream JSX, without saving partial designs or adding intermediate undo steps. A preview stays with its page: it hides while you view another page and returns when you come back.
@ -75,6 +77,7 @@
### Security
- Validate cursors, selections, and names that collaborators send before drawing them, and cap their size, so a broken or hostile peer cannot crash or flood the canvas.
- Evaluate `calc` expressions through `jsep` and an arithmetic allowlist that never compiles input into JavaScript, replacing the `expr-eval` dependency and its unpatched critical code-execution advisory (GHSA-q9v2-7m5w-4693).
- Escape layer names and other text properties in JSX and Tailwind JSX export, so text from a document can no longer add attributes or JavaScript expressions that the AI and MCP `render` and `replace` tools would execute, and names containing `&` no longer change when the JSX is rendered back.

View file

@ -14,4 +14,4 @@ export { computeGuideRedline } from './guides/redlines'
export { hitTestGuides, type GuideHit } from './guides/hit-test'
export type { GuideOverlayState, GuidePreview, GuideSelection } from './guides/types'
export { canvasLabelForeground } from './labels/color'
export { SkiaRenderer, type RenderOverlays, type RulerTheme } from './renderer'
export { SkiaRenderer, type PresenceCursor, type RenderOverlays, type RulerTheme } from './renderer'

View file

@ -14,5 +14,6 @@ export { drawFlashes, drawLayoutInsertIndicator, drawMarquee, drawSnapGuides } f
export { drawMeasurements } from './measurement'
export { drawTextEditOverlay } from './text-edit'
export { drawSelectionLabels } from '#core/canvas/labels/selection'
export { drawPenOverlay, drawRemoteCursors } from '#core/canvas/pen-overlay'
export { drawPenOverlay } from '#core/canvas/pen-overlay'
export { drawPresenceCursors } from './presence'
export { computeHandleVisibleVertices, drawNodeEditOverlay } from '#core/canvas/node-edit-overlay'

View file

@ -0,0 +1,166 @@
import type { Canvas } from 'canvaskit-wasm'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate'
import Matrix from '@open-pencil/scene-graph/matrix'
import type { PresenceCursor, SkiaRenderer } from '#core/canvas/renderer'
const CURSOR_SIZE = 9
const LABEL_PADDING_X = 4
const LABEL_PADDING_Y = 2
const LABEL_FONT_SIZE = 10
const LABEL_OFFSET_X = 12
const LABEL_OFFSET_Y = 20
/** Longer names end with an ellipsis. */
const LABEL_MAX_WIDTH = 160
/** The sparkle that marks an agent's name. */
const LABEL_SPARKLE_RADIUS = 4
const LABEL_SPARKLE_GAP = 3
function drawSelection(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph, cursor: PresenceCursor) {
if (!cursor.selection?.length) return
const { r: red, g, b } = cursor.color
r.auxStroke.setColor(r.ck.Color4f(red, g, b, 0.6))
r.auxStroke.setStrokeWidth(1.5)
r.auxStroke.setPathEffect(null)
for (const nodeId of cursor.selection) {
const node = graph.getNode(nodeId)
if (!node) continue
// Map the corners through the node's world matrix, as the shape is rendered, so the
// box tracks rotation, flips, and parent transforms.
const corners = Matrix.mapPoints(getWorldMatrix(node, graph), [
0,
0,
node.width,
0,
node.width,
node.height,
0,
node.height
])
const box = new r.ck.PathBuilder()
box.moveTo(corners[0] * r.zoom + r.panX, corners[1] * r.zoom + r.panY)
for (let i = 2; i < corners.length; i += 2) {
box.lineTo(corners[i] * r.zoom + r.panX, corners[i + 1] * r.zoom + r.panY)
}
box.close()
const path = box.detachAndDelete()
canvas.drawPath(path, r.auxStroke)
path.delete()
}
}
function arrowPath(r: SkiaRenderer, x: number, y: number) {
const S = CURSOR_SIZE
const builder = new r.ck.PathBuilder()
builder.moveTo(x, y)
builder.lineTo(x, y + S * 1.35)
builder.lineTo(x + S * 0.38, y + S * 1.0)
builder.lineTo(x + S * 0.72, y + S * 1.5)
builder.lineTo(x + S * 0.92, y + S * 1.38)
builder.lineTo(x + S * 0.58, y + S * 0.88)
builder.lineTo(x + S * 1.0, y + S * 0.82)
builder.close()
return builder.detachAndDelete()
}
/** A four-point sparkle of radius `R` centered on `cx`, `cy`. */
function sparklePath(r: SkiaRenderer, cx: number, cy: number, R: number) {
const inner = R * 0.32
const builder = new r.ck.PathBuilder()
builder.moveTo(cx, cy - R)
builder.lineTo(cx + inner, cy - inner)
builder.lineTo(cx + R, cy)
builder.lineTo(cx + inner, cy + inner)
builder.lineTo(cx, cy + R)
builder.lineTo(cx - inner, cy + inner)
builder.lineTo(cx - R, cy)
builder.lineTo(cx - inner, cy - inner)
builder.close()
return builder.detachAndDelete()
}
/** The pointer, filled with a person's color, or for an agent its owner's, and outlined in white. */
function drawArrow(r: SkiaRenderer, canvas: Canvas, x: number, y: number, cursor: PresenceCursor) {
const path = arrowPath(r, x, y)
r.auxStroke.setColor(r.ck.WHITE)
r.auxStroke.setStrokeWidth(2)
r.auxStroke.setPathEffect(null)
canvas.drawPath(path, r.auxStroke)
const { r: red, g, b } = cursor.color
r.auxFill.setColor(r.ck.Color4f(red, g, b, 1))
canvas.drawPath(path, r.auxFill)
path.delete()
}
/**
* A name pill: filled for people; for agents outlined in the owner's color, with a
* sparkle before the name.
*/
function drawLabel(r: SkiaRenderer, canvas: Canvas, x: number, y: number, cursor: PresenceCursor) {
const provider = r.fontProvider
if (!cursor.name || !provider) return
const { r: red, g, b } = cursor.color
const color = r.ck.Color4f(red, g, b, 1)
const agent = cursor.kind === 'agent'
const badge = agent ? LABEL_SPARKLE_RADIUS * 2 + LABEL_SPARKLE_GAP : 0
const pillX = x + LABEL_OFFSET_X - LABEL_PADDING_X
const pillY = y + LABEL_OFFSET_Y - LABEL_FONT_SIZE - LABEL_PADDING_Y + 2
const pillHeight = LABEL_FONT_SIZE + LABEL_PADDING_Y * 2
// Shaped like other canvas labels: kerning, fallback fonts, and RTL names.
r.labelParagraphCache.use(
r.ck,
provider,
cursor.name,
LABEL_FONT_SIZE,
LABEL_MAX_WIDTH,
agent ? color : r.ck.WHITE,
r.fontGeneration,
({ paragraph, metrics }) => {
const pill = r.ck.RRectXY(
r.ck.XYWHRect(pillX, pillY, badge + metrics.width + LABEL_PADDING_X * 2, pillHeight),
4,
4
)
r.auxFill.setColor(agent ? r.ck.WHITE : color)
canvas.drawRRect(pill, r.auxFill)
if (agent) {
r.auxStroke.setColor(color)
r.auxStroke.setStrokeWidth(1)
r.auxStroke.setPathEffect(null)
canvas.drawRRect(pill, r.auxStroke)
const sparkle = sparklePath(
r,
pillX + LABEL_PADDING_X + LABEL_SPARKLE_RADIUS,
pillY + pillHeight / 2,
LABEL_SPARKLE_RADIUS
)
r.auxFill.setColor(color)
canvas.drawPath(sparkle, r.auxFill)
sparkle.delete()
}
canvas.drawParagraph(
paragraph,
pillX + LABEL_PADDING_X + badge,
pillY + (pillHeight - metrics.height) / 2
)
}
)
}
/** Cursors of people and agents in screen space, with outlines of what they selected. */
export function drawPresenceCursors(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
cursors?: PresenceCursor[]
): void {
for (const cursor of cursors ?? []) {
const x = cursor.x * r.zoom + r.panX
const y = cursor.y * r.zoom + r.panY
drawSelection(r, canvas, graph, cursor)
drawArrow(r, canvas, x, y, cursor)
drawLabel(r, canvas, x, y, cursor)
}
}

View file

@ -1,8 +1,5 @@
import type { Canvas, Paint } from 'canvaskit-wasm'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate'
import Matrix from '@open-pencil/scene-graph/matrix'
import type { Vector } from '@open-pencil/scene-graph/primitives'
import { PEN_HANDLE_RADIUS, PEN_VERTEX_RADIUS, PEN_CLOSE_RADIUS_BOOST } from '#core/constants'
@ -192,112 +189,3 @@ export function drawPenOverlay(
canvas.drawCircle(v.x, v.y, radius, vertexStroke)
}
}
export function drawRemoteCursors(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
cursors?: RenderOverlays['remoteCursors']
): void {
if (!cursors || cursors.length === 0) return
const CURSOR_SIZE = 9
const LABEL_PADDING_X = 4
const LABEL_PADDING_Y = 2
const LABEL_FONT_SIZE = 10
const LABEL_OFFSET_X = 12
const LABEL_OFFSET_Y = 20
/** Longer names end with an ellipsis. */
const LABEL_MAX_WIDTH = 160
for (const cursor of cursors) {
const screenX = cursor.x * r.zoom + r.panX
const screenY = cursor.y * r.zoom + r.panY
const { r: cr, g, b } = cursor.color
if (cursor.selection?.length) {
r.auxStroke.setColor(r.ck.Color4f(cr, g, b, 0.6))
r.auxStroke.setStrokeWidth(1.5)
r.auxStroke.setPathEffect(null)
for (const nodeId of cursor.selection) {
const node = graph.getNode(nodeId)
if (!node) continue
// Map the node's four corners through its world matrix (the same transform
// the shape is rendered with) so a remote peer's selection box tracks the
// node exactly, including rotation, flips, and any parent transforms.
const m = getWorldMatrix(node, graph)
const c = Matrix.mapPoints(m, [
0,
0,
node.width,
0,
node.width,
node.height,
0,
node.height
])
const box = new r.ck.PathBuilder()
box.moveTo(c[0] * r.zoom + r.panX, c[1] * r.zoom + r.panY)
for (let i = 2; i < c.length; i += 2) {
box.lineTo(c[i] * r.zoom + r.panX, c[i + 1] * r.zoom + r.panY)
}
box.close()
const immutableBox = box.detachAndDelete()
canvas.drawPath(immutableBox, r.auxStroke)
immutableBox.delete()
}
}
const S = CURSOR_SIZE
const path = new r.ck.PathBuilder()
path.moveTo(screenX, screenY)
path.lineTo(screenX, screenY + S * 1.35)
path.lineTo(screenX + S * 0.38, screenY + S * 1.0)
path.lineTo(screenX + S * 0.72, screenY + S * 1.5)
path.lineTo(screenX + S * 0.92, screenY + S * 1.38)
path.lineTo(screenX + S * 0.58, screenY + S * 0.88)
path.lineTo(screenX + S * 1.0, screenY + S * 0.82)
path.close()
const immutablePath = path.detachAndDelete()
r.auxStroke.setColor(r.ck.Color4f(1, 1, 1, 1))
r.auxStroke.setStrokeWidth(2)
r.auxStroke.setPathEffect(null)
canvas.drawPath(immutablePath, r.auxStroke)
r.auxFill.setColor(r.ck.Color4f(cr, g, b, 1))
canvas.drawPath(immutablePath, r.auxFill)
immutablePath.delete()
const provider = r.fontProvider
if (cursor.name && provider) {
const pillX = screenX + LABEL_OFFSET_X - LABEL_PADDING_X
const pillY = screenY + LABEL_OFFSET_Y - LABEL_FONT_SIZE - LABEL_PADDING_Y + 2
const pillHeight = LABEL_FONT_SIZE + LABEL_PADDING_Y * 2
// Shaped like other canvas labels: kerning, fallback fonts, and RTL names.
r.labelParagraphCache.use(
r.ck,
provider,
cursor.name,
LABEL_FONT_SIZE,
LABEL_MAX_WIDTH,
r.ck.WHITE,
r.fontGeneration,
({ paragraph, metrics }) => {
r.auxFill.setColor(r.ck.Color4f(cr, g, b, 1))
const pill = r.ck.RRectXY(
r.ck.XYWHRect(pillX, pillY, metrics.width + LABEL_PADDING_X * 2, pillHeight),
4,
4
)
canvas.drawRRect(pill, r.auxFill)
canvas.drawParagraph(
paragraph,
pillX + LABEL_PADDING_X,
pillY + (pillHeight - metrics.height) / 2
)
}
)
}
}
}

View file

@ -36,7 +36,7 @@ import * as RendererState from './renderer/state'
import * as RenderText from './text'
import { createGlyphSilhouetteCache } from './text/derived'
import { TextPreparationCache } from './text/preparation-cache'
export type { MeasurementMode, RenderOverlays, RulerTheme } from './renderer/types'
export type { MeasurementMode, PresenceCursor, RenderOverlays, RulerTheme } from './renderer/types'
import type {
Image as CKImage,
Path,
@ -70,7 +70,7 @@ export interface PendingFontNode {
import { EffectRasterCache } from './renderer/effect-raster-cache'
import { TiledSceneController } from './renderer/tiles'
import type { TransientCanvasPreview } from './renderer/transient-previews'
import type { RenderOverlays, RulerTheme } from './renderer/types'
import type { PresenceCursor, RenderOverlays, RulerTheme } from './renderer/types'
export class SkiaRenderer {
ck: CanvasKit
@ -266,10 +266,10 @@ export class SkiaRenderer {
editState?: RenderOverlays['nodeEditState']
) => void
declare drawPenOverlay: (canvas: Canvas, penState: RenderOverlays['penState']) => void
declare drawRemoteCursors: (
declare drawPresenceCursors: (
canvas: Canvas,
graph: SceneGraph,
cursors?: RenderOverlays['remoteCursors']
cursors?: PresenceCursor[]
) => void
declare drawRulers: (
canvas: Canvas,

View file

@ -13,7 +13,7 @@ import * as Overlays from '#core/canvas/overlays'
import * as AIOverlays from '#core/canvas/overlays/ai'
import * as PenOverlay from '#core/canvas/pen-overlay'
import type { SkiaRenderer } from '#core/canvas/renderer'
import type { RenderOverlays } from '#core/canvas/renderer/types'
import type { PresenceCursor, RenderOverlays } from '#core/canvas/renderer/types'
import * as Rulers from '#core/canvas/rulers'
import * as SceneRender from '#core/canvas/scene'
import { renderEffects as renderShadowEffects } from '#core/canvas/shadows'
@ -162,12 +162,8 @@ const rendererMethods: ThisType<SkiaRenderer> = {
PenOverlay.drawPenOverlay(this, canvas, penState)
},
drawRemoteCursors(
canvas: Canvas,
graph: SceneGraph,
cursors?: RenderOverlays['remoteCursors']
): void {
PenOverlay.drawRemoteCursors(this, canvas, graph, cursors)
drawPresenceCursors(canvas: Canvas, graph: SceneGraph, cursors?: PresenceCursor[]): void {
Overlays.drawPresenceCursors(this, canvas, graph, cursors)
},
drawRulers(

View file

@ -59,7 +59,7 @@ export function drawOverlayPass(
if (!measuring) r.drawAutoLayoutHover(canvas, graph, overlays.autoLayoutHover)
r.drawNodeEditOverlay(canvas, graph, overlays.nodeEditState)
r.drawPenOverlay(canvas, overlays.penState)
r.drawRemoteCursors(canvas, graph, overlays.remoteCursors)
r.drawPresenceCursors(canvas, graph, overlays.presenceCursors)
}
export function drawChromePass(

View file

@ -78,7 +78,7 @@ export function renderFromEditorState(
} as RenderOverlays['penState'])
: null,
nodeEditState: state.nodeEditState ?? null,
remoteCursors: state.remoteCursors,
presenceCursors: state.presenceCursors,
autoLayoutHover: state.autoLayoutHover
},
state.sceneVersion,

View file

@ -15,6 +15,17 @@ export interface RulerTheme {
export type MeasurementMode = 'off' | 'shallow' | 'deep'
/** Where a collaborator or an agent is working, in world coordinates. */
export interface PresenceCursor {
kind: 'person' | 'agent'
name: string
/** A person's color, or the color of the person who runs the agent. */
color: Color
x: number
y: number
selection?: string[]
}
export interface RenderOverlays {
hoveredNodeId?: string | null
measurementMode?: MeasurementMode
@ -67,11 +78,5 @@ export interface RenderOverlays {
selectedHandles?: Set<number>
hoveredHandleInfo?: { segmentIndex: number; tangentField: 'tangentStart' | 'tangentEnd' } | null
} | null
remoteCursors?: Array<{
name: string
color: Color
x: number
y: number
selection?: string[]
}>
presenceCursors?: PresenceCursor[]
}

View file

@ -5,7 +5,7 @@ export function createDefaultEditorSharedState(): EditorSharedState {
return {
activeTool: 'SELECT',
snappingPreferences: { ...DEFAULT_SNAPPING_PREFERENCES },
remoteCursors: [],
presenceCursors: [],
documentName: 'Untitled',
rulerTheme: undefined,
sceneVersion: 0

View file

@ -15,7 +15,7 @@ import type { UndoManager } from '@open-pencil/scene-graph/undo'
import type { GuideOverlayState } from '#core/canvas/guides/types'
import type { RulerTheme, SkiaRenderer } from '#core/canvas/renderer'
import type { MeasurementMode, RenderOverlays } from '#core/canvas/renderer/types'
import type { MeasurementMode, PresenceCursor, RenderOverlays } from '#core/canvas/renderer/types'
import type { SnappingPreferences } from '#core/editor/preferences'
import type { RotationPreview } from '#core/geometry'
import type { TextEditor } from '#core/text/editor'
@ -37,13 +37,7 @@ export type Tool =
export interface EditorSharedState {
activeTool: Tool
snappingPreferences: SnappingPreferences
remoteCursors: Array<{
name: string
color: Color
x: number
y: number
selection?: string[]
}>
presenceCursors: PresenceCursor[]
documentName: string
rulerTheme?: RulerTheme
sceneVersion: number

View file

@ -20,6 +20,7 @@ Anyone with the link can join. The room stays active as long as at least one par
- **Document changes** — every edit (shapes, text, properties, layout) syncs instantly
- **Cursors** — see where each collaborator is pointing, with their name and color
- **Selections** — highlighted selections are visible to everyone
- **Agents** — the built-in AI chat appears as a cursor at the layers it is editing, its outlined label showing a sparkle and a callsign such as *Fern*. The cursor and outline have the color of the person running it, so you can tell whose agent it is. Only its name, kind, model, status, page, position, and edited layers are shared, never prompts or replies.
## Follow Mode

View file

@ -0,0 +1,54 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import PresenceCursors from './examples/PresenceCursors.vue'
type Args = {
person: string
personColor: string
agent: string
otherAgent: string
otherColor: string
zoom: number
}
const meta = {
title: 'Vue SDK/Canvas/Presence Cursors',
component: PresenceCursors,
parameters: {
docs: {
description: {
component:
'People draw as arrows filled with their color; agents in the color of the person running them, with an outlined label that starts with a sparkle, and what they edit outlined.'
}
}
},
args: {
person: 'Ana',
personColor: '#eb574a',
agent: 'Fern',
otherAgent: 'Orbit',
otherColor: '#338cf2',
zoom: 1
},
argTypes: {
personColor: { control: 'color' },
otherColor: { control: 'color' },
zoom: { control: { type: 'range', min: 0.25, max: 4, step: 0.25 } }
}
} satisfies Meta<Args>
export default meta
type Story = StoryObj<Meta<Args>>
export const Room: Story = {}
export const LongNames: Story = {
args: {
person: 'A collaborator with a very long display name',
agent: 'An agent callsign far longer than any we hand out'
}
}
export const ZoomedIn: Story = {
args: { zoom: 2 }
}

View file

@ -0,0 +1,53 @@
<script setup lang="ts">
import { watchEffect } from 'vue'
import { createEditor } from '@open-pencil/core/editor'
import { parseColor } from '@open-pencil/scene-graph/color'
import CanvasRoot from '#vue/canvas/CanvasRoot.vue'
import CanvasSurface from '#vue/canvas/CanvasSurface.vue'
import { provideEditor } from '#vue/editor/context'
const { person, personColor, agent, otherAgent, otherColor, zoom } = defineProps<{
person: string
personColor: string
agent: string
otherAgent: string
otherColor: string
zoom: number
}>()
/** The scene's middle in canvas pixels, which zooming keeps in place. */
const CENTER = { x: 300, y: 200 }
const editor = createEditor()
const card = editor.graph.createNode('RECTANGLE', editor.state.currentPageId, {
name: 'Card',
x: 220,
y: 200,
width: 160,
height: 100,
fills: [{ type: 'SOLID', color: parseColor('#e8e8eb'), opacity: 1, visible: true }]
})
provideEditor(editor)
watchEffect(() => {
const owner = parseColor(personColor)
const other = parseColor(otherColor)
editor.state.panX = CENTER.x * (1 - zoom)
editor.state.panY = CENTER.y * (1 - zoom)
editor.state.zoom = zoom
editor.state.presenceCursors = [
{ kind: 'person', name: person, color: owner, x: 120, y: 100 },
{ kind: 'agent', name: agent, color: owner, x: 220, y: 200, selection: [card.id] },
{ kind: 'agent', name: otherAgent, color: other, x: 430, y: 150 }
]
editor.requestRender()
})
</script>
<template>
<CanvasRoot :show-rulers="false">
<CanvasSurface class="block h-[400px] w-[600px]" />
</CanvasRoot>
</template>

View file

@ -1,4 +1,5 @@
import { Chat } from '@ai-sdk/vue'
import { useEventListener } from '@vueuse/core'
import { createUIMessageStream, DirectChatTransport, stepCountIs, ToolLoopAgent } from 'ai'
import type {
ChatTransport,
@ -21,7 +22,7 @@ import { chatThinkingLevel } from '@/app/ai/chat/thinking'
import { createAIModelRuntime, resolveModelConnectionAPIKey } from '@/app/ai/models'
import type { ThinkingLevel } from '@/app/ai/models/types'
import { createCanvasJSXPreview } from '@/app/ai/preview/canvas'
import { createAITools, recordStep, runPageId, startRun } from '@/app/ai/tools'
import { createAITools, endRun, recordStep, runPageId, startRun } from '@/app/ai/tools'
import { enabledAIToolDefinitions } from '@/app/ai/tools/catalog'
import { aiToolOverrides } from '@/app/ai/tools/preferences'
import {
@ -129,7 +130,7 @@ export function createToolLoopTransport({
enabledAIToolDefinitions(aiToolOverrides.value).map((tool) => tool.name)
)
preview.clear()
startRun(store, stepLimit)
startRun(store, stepLimit, effectiveModelID)
return {
...options,
stopWhen: stepCountIs(stepLimit),
@ -139,7 +140,10 @@ export function createToolLoopTransport({
...callSettings(providerID, cacheProviderOptions, thinkingLevel())
}
},
onFinish: () => preview.clear(),
onFinish: () => {
preview.clear()
endRun(store)
},
onStepFinish: ({ usage }) => {
preview.clear()
recordStep(store)
@ -159,6 +163,7 @@ export function createToolLoopTransport({
function handleError(error: unknown): string {
preview.clear()
endRun(store)
onError?.(error)
return 'The provider rejected the request.'
}
@ -169,6 +174,8 @@ export function createToolLoopTransport({
return resumableTransport({
reconnectToStream: (options) => transport.reconnectToStream(options),
async sendMessages(options) {
// Stopping a reply ends the run without onFinish.
if (options.abortSignal) useEventListener(options.abortSignal, 'abort', () => endRun(store))
// DirectChatTransport handles error chunks, but not a rejected underlying stream.
return createUIMessageStream<UIMessage>({
execute: async ({ writer }) => {

View file

@ -10,9 +10,9 @@ import { ensureGraphFonts } from '@/app/editor/fonts'
import { useLibraryService } from '@/app/libraries'
import { aiToolDefinitions } from './catalog'
import { moveRunToPage, runPageId, stepBudget } from './run'
import { markRunWork, moveRunToPage, runPageId, stepBudget } from './run'
export { didHitStepLimit, recordStep, runPageId, startRun } from './run'
export { didHitStepLimit, endRun, recordStep, runPageId, startRun } from './run'
export function createAITools(store: EditorStore, diagnosticContext?: AIDiagnosticContext) {
let before: { pageId: string; snapshot: ReturnType<EditorStore['snapshotPage']> } | null = null
@ -62,6 +62,7 @@ export function createAITools(store: EditorStore, diagnosticContext?: AIDiagnost
}
},
onFlashNodes: (nodeIds) => {
markRunWork(store, nodeIds)
store.renderer?.aiClearActive()
if (nodeIds.length > 0) {
store.aiFlashDone(nodeIds)

View file

@ -1,8 +1,10 @@
import { computeContentBounds } from '@open-pencil/core/io'
import type { StepBudget } from '@open-pencil/core/tools'
import { DEFAULT_AGENT_STEPS, resolveAgentStepLimit } from '@/app/ai/chat/step-limit'
import { getActiveEditorStore } from '@/app/editor/active-store'
import type { EditorStore } from '@/app/editor/active-store'
import { addAgent, type AgentHandle } from '@/app/presence/registry'
class RunState {
currentSteps = 0
@ -10,6 +12,8 @@ class RunState {
maxSteps = DEFAULT_AGENT_STEPS
/** The page the run works on. The user's navigation does not move it; the agent's does. */
pageId: string | null = null
/** The built-in chat as other people see it; one per document, kept between replies. */
agent: AgentHandle | null = null
start(maxSteps: number, pageId: string): void {
this.currentSteps = 0
@ -34,8 +38,28 @@ function getRunState(store?: EditorStore): RunState {
}
/** Begin a run on the page the user is viewing. */
export function startRun(store: EditorStore, maxSteps: number): void {
getRunState(store).start(maxSteps, store.state.currentPageId)
export function startRun(store: EditorStore, maxSteps: number, model?: string): void {
const run = getRunState(store)
run.start(maxSteps, store.state.currentPageId)
run.agent ??= addAgent(store, 'chat', model)
run.agent.update({ status: 'thinking', model })
}
/** The reply finished, failed, or was stopped: the agent stays listed but leaves the canvas. */
export function endRun(store: EditorStore): void {
getRunState(store).agent?.update({ status: 'idle', cursor: undefined, selection: undefined })
}
/** Point the run's agent at nodes a tool just created or changed. */
export function markRunWork(store: EditorStore, nodeIds: string[]): void {
const run = getRunState(store)
const bounds = computeContentBounds(store.graph, nodeIds)
if (!run.agent || !bounds) return
run.agent.update({
status: 'editing',
cursor: { x: bounds.minX, y: bounds.minY, pageId: runPageId(store) },
selection: nodeIds
})
}
export function recordStep(store?: EditorStore): void {

View file

@ -2,10 +2,10 @@ import { ref } from 'vue'
import type * as awarenessProtocol from 'y-protocols/awareness'
import { randomIndex } from '@open-pencil/core/random'
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { EditorStore } from '@/app/editor/active-store'
import { PEER_COLORS, ROOM_ID_CHARS, ROOM_ID_LENGTH } from '@/constants'
import { parsePeer } from '@/app/presence/schema'
import { ROOM_ID_CHARS, ROOM_ID_LENGTH } from '@/constants'
import type { RemotePeer } from './types'
@ -18,41 +18,14 @@ type CursorState = {
zoom?: number
}
/** People in the room other than us, validated: awareness comes from other browsers. */
export function buildRemotePeers(
states: Map<number, Record<string, unknown>>,
localClientId: number
): RemotePeer[] {
const peers: RemotePeer[] = []
states.forEach((peerState, clientId) => {
if (clientId === localClientId) return
const user = peerState.user as { name?: string; color?: Color } | undefined
if (!user) return
peers.push({
clientId,
name: user.name || 'Anonymous',
color: user.color || PEER_COLORS[clientId % PEER_COLORS.length],
cursor: peerState.cursor as RemotePeer['cursor'],
selection: peerState.selection as string[]
})
})
return peers
}
export function remotePeersToCursors(peers: RemotePeer[], currentPageId: string) {
return peers
.filter((p) => p.cursor && p.cursor.pageId === currentPageId)
.map((p) => {
const cursor = p.cursor as NonNullable<RemotePeer['cursor']>
return {
name: p.name,
color: p.color,
x: cursor.x,
y: cursor.y,
selection: p.selection
}
})
return [...states].flatMap(([clientId, state]) =>
clientId === localClientId ? [] : (parsePeer(clientId, state) ?? [])
)
}
export function createFollowActions(

View file

@ -1,9 +1,12 @@
import type { Ref } from 'vue'
import { watch, type Ref } from 'vue'
import type { Awareness } from 'y-protocols/awareness'
import { buildRemotePeers, remotePeersToCursors } from '@/app/collab/awareness'
import type { Color } from '@open-pencil/scene-graph/primitives'
import { buildRemotePeers } from '@/app/collab/awareness'
import type { CollabState } from '@/app/collab/types'
import type { EditorStore } from '@/app/editor/active-store'
import { presenceOf, setOwnerColor, setPeers } from '@/app/presence/registry'
type LocalAwarenessOptions = {
state: Ref<CollabState>
@ -50,8 +53,7 @@ export function createLocalAwarenessActions({
)
state.value.peers = peers
store.state.remoteCursors = remotePeersToCursors(peers, store.state.currentPageId)
store.requestRender()
setPeers(store, peers)
}
function setLocalName(name: string) {
@ -62,3 +64,21 @@ export function createLocalAwarenessActions({
return { broadcastAwareness, updateCursor, updateSelection, updatePeersList, setLocalName }
}
/** Publish our agents to the room while connected; they take our collaborator color. */
export function publishLocalAgents(
store: EditorStore,
getAwareness: () => Awareness | null,
color: Color
): () => void {
setOwnerColor(store, color)
const stop = watch(
presenceOf(store).agents,
(agents) => getAwareness()?.setLocalStateField('agents', agents),
{ immediate: true }
)
return () => {
stop()
setOwnerColor(store, null)
}
}

View file

@ -6,11 +6,13 @@ import * as Y from 'yjs'
import { randomIndex } from '@open-pencil/core/random'
import { publishLocalAgents } from '@/app/collab/local-awareness'
import { connectCollabRoom } from '@/app/collab/room'
import type { CollabRoomTransport } from '@/app/collab/transport'
import type { CollabState } from '@/app/collab/types'
import { bindCollabGraphEvents, registerYjsObservers } from '@/app/collab/yjs-sync'
import type { EditorStore } from '@/app/editor/active-store'
import { setPeers } from '@/app/presence/registry'
import { PEER_COLORS } from '@/constants'
export type CollabRuntime = {
@ -25,6 +27,7 @@ export type CollabRuntime = {
suppressYjsEvents: boolean
unbindGraphEvents: (() => void) | null
stopZoomWatch: (() => void) | null
stopAgentSync: (() => void) | null
}
type ConnectCollabSessionOptions = {
@ -60,6 +63,7 @@ type CollabSessionResources = {
ydoc: Y.Doc | null
unbindGraphEvents: (() => void) | null
stopZoomWatch: (() => void) | null
stopAgentSync: (() => void) | null
resetFollow: () => void
}
@ -75,7 +79,8 @@ export function createCollabRuntime(): CollabRuntime {
suppressGraphSync: false,
suppressYjsEvents: false,
unbindGraphEvents: null,
stopZoomWatch: null
stopZoomWatch: null,
stopAgentSync: null
}
}
@ -125,6 +130,7 @@ export function createCollabConnectionActions({
ydoc: runtime.ydoc,
unbindGraphEvents: runtime.unbindGraphEvents,
stopZoomWatch: runtime.stopZoomWatch,
stopAgentSync: runtime.stopAgentSync,
resetFollow
})
resetCollabRuntime(runtime)
@ -199,6 +205,7 @@ export function connectCollabSession({
broadcastAwareness()
runtime.stopZoomWatch = watchAwarenessZoom(store, () => runtime.awareness)
runtime.stopAgentSync = publishLocalAgents(store, () => runtime.awareness, state.value.localColor)
runtime.unbindGraphEvents = bindCollabGraphEvents({
store,
@ -215,6 +222,7 @@ export function connectCollabSession({
export function resetCollabRuntime(runtime: CollabRuntime) {
runtime.unbindGraphEvents = null
runtime.stopZoomWatch = null
runtime.stopAgentSync = null
runtime.room = null
runtime.awareness = null
runtime.persistence = null
@ -239,7 +247,7 @@ export function disposeCollabSessionResources(resources: CollabSessionResources)
void resources.persistence.destroy()
}
resources.ydoc?.destroy()
resources.stopAgentSync?.()
resources.resetFollow()
resources.store.state.remoteCursors = []
resources.store.requestRender()
setPeers(resources.store, [])
}

View file

@ -1,11 +1,15 @@
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { AgentPresence, PresencePoint } from '@/app/presence/types'
export interface RemotePeer {
clientId: number
name: string
color: Color
cursor?: { x: number; y: number; pageId: string }
cursor?: PresencePoint
selection?: string[]
/** Agents this person runs, as they publish them. */
agents: AgentPresence[]
}
export interface CollabState {

View file

@ -0,0 +1,48 @@
import { randomIndex } from '@open-pencil/core/random'
/** Short, distinct names that are easy to say: "ask Fern to align the cards". */
const CALLSIGNS = [
'Fern',
'Orbit',
'Pixel',
'Juniper',
'Comet',
'Maple',
'Nova',
'Pebble',
'Quill',
'Sable',
'Tango',
'Willow',
'Echo',
'Indigo',
'Kite',
'Lumen',
'Mosaic',
'Nimbus',
'Opal',
'Pico',
'Rune',
'Sorrel',
'Tidal',
'Vesper',
'Wren',
'Zephyr',
'Atlas',
'Basil',
'Cinder',
'Dune',
'Ember',
'Flint'
] as const
/** A callsign not in `taken`, picked at random so peers rarely choose the same one. */
export function pickCallsign(taken: ReadonlySet<string>): string {
const free = CALLSIGNS.filter((name) => !taken.has(name))
if (free.length > 0) return free[randomIndex(free.length)] ?? CALLSIGNS[0]
// Every callsign is taken: number the first one that is still unique.
for (let n = 2; ; n++) {
const name = `${CALLSIGNS[0]} ${n}`
if (!taken.has(name)) return name
}
}

View file

@ -0,0 +1,121 @@
import { shallowRef, type ShallowRef } from 'vue'
import type { PresenceCursor } from '@open-pencil/core/canvas'
import { AI_ACTIVE_COLOR } from '@open-pencil/core/constants'
import { randomHex } from '@open-pencil/core/random'
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { RemotePeer } from '@/app/collab/types'
import type { EditorStore } from '@/app/editor/active-store'
import { pickCallsign } from './callsigns'
import type { AgentKind, AgentPresence } from './types'
/** Agents' color outside a room, where there is no collaborator color to inherit. */
const SOLO_AGENT_COLOR: Color = { ...AI_ACTIVE_COLOR, a: 1 }
interface Presence {
/** Agents running in this app for the document. */
agents: ShallowRef<readonly AgentPresence[]>
/** People in the room, with their agents. */
peers: ShallowRef<readonly RemotePeer[]>
/** The local collaborator's color while in a room. */
ownerColor: ShallowRef<Color | null>
}
const presences = new WeakMap<EditorStore, Presence>()
export function presenceOf(store: EditorStore): Presence {
const existing = presences.get(store)
if (existing) return existing
const presence: Presence = {
agents: shallowRef([]),
peers: shallowRef([]),
ownerColor: shallowRef(null)
}
presences.set(store, presence)
store.onEditorEvent('page:changed', () => refreshCursors(store))
return presence
}
function agentCursor(agent: AgentPresence, color: Color, pageId: string): PresenceCursor[] {
if (agent.status === 'idle' || agent.cursor?.pageId !== pageId) return []
const { x, y } = agent.cursor
return [{ kind: 'agent', name: agent.name, color, x, y, selection: agent.selection }]
}
/** Draw everyone working on the page on screen: people, their agents, and ours. */
export function refreshCursors(store: EditorStore): void {
const { agents, peers, ownerColor } = presenceOf(store)
const pageId = store.state.currentPageId
store.state.presenceCursors = [
...peers.value.flatMap((peer): PresenceCursor[] => [
...(peer.cursor?.pageId === pageId
? [
{
kind: 'person' as const,
name: peer.name,
color: peer.color,
...peer.cursor,
selection: peer.selection
}
]
: []),
...peer.agents.flatMap((agent) => agentCursor(agent, peer.color, pageId))
]),
...agents.value.flatMap((agent) =>
agentCursor(agent, ownerColor.value ?? SOLO_AGENT_COLOR, pageId)
)
]
store.requestRepaint()
}
export function setPeers(store: EditorStore, peers: readonly RemotePeer[]): void {
presenceOf(store).peers.value = peers
refreshCursors(store)
}
export function setOwnerColor(store: EditorStore, color: Color | null): void {
presenceOf(store).ownerColor.value = color
refreshCursors(store)
}
export interface AgentHandle {
readonly id: string
readonly name: string
update: (patch: Partial<Omit<AgentPresence, 'id' | 'name' | 'kind'>>) => void
remove: () => void
}
/** Announce an agent working in this document under a callsign unique among visible agents. */
export function addAgent(store: EditorStore, kind: AgentKind, model?: string): AgentHandle {
const presence = presenceOf(store)
const taken = new Set([
...presence.agents.value.map((agent) => agent.name),
...presence.peers.value.flatMap((peer) => peer.agents.map((agent) => agent.name))
])
const agent: AgentPresence = {
id: randomHex(8),
name: pickCallsign(taken),
kind,
model,
status: 'idle'
}
presence.agents.value = [...presence.agents.value, agent]
const replace = (next: AgentPresence | null) => {
const agents = presence.agents.value
presence.agents.value = next
? agents.map((entry) => (entry.id === agent.id ? next : entry))
: agents.filter((entry) => entry.id !== agent.id)
refreshCursors(store)
}
return {
id: agent.id,
name: agent.name,
update: (patch) => {
const current = presence.agents.value.find((entry) => entry.id === agent.id)
if (current) replace({ ...current, ...patch })
},
remove: () => replace(null)
}
}

View file

@ -0,0 +1,60 @@
import * as v from 'valibot'
import type { RemotePeer } from '@/app/collab/types'
import { PEER_COLORS } from '@/constants'
import { AGENT_KINDS, AGENT_STATUSES } from './types'
/** Bounds on what a peer can publish, so a broken or hostile peer cannot flood rendering. */
export const MAX_AGENTS_PER_PEER = 16
const MAX_SELECTION = 256
const MAX_NAME = 40
const MAX_ID = 64
const finite = v.pipe(v.number(), v.finite())
const unit = v.pipe(finite, v.minValue(0), v.maxValue(1))
const id = v.pipe(v.string(), v.maxLength(MAX_ID))
const name = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(MAX_NAME))
/** Optional fields fall back to absent instead of discarding the whole peer. */
const lenient = <T extends v.GenericSchema>(schema: T) => v.fallback(v.optional(schema), undefined)
const color = v.object({ r: unit, g: unit, b: unit, a: v.fallback(unit, 1) })
const point = v.object({ x: finite, y: finite, pageId: id })
const selection = v.pipe(v.array(id), v.maxLength(MAX_SELECTION))
const agent = v.object({
id,
name,
kind: v.picklist(AGENT_KINDS),
model: lenient(v.pipe(v.string(), v.maxLength(MAX_NAME))),
status: v.picklist(AGENT_STATUSES),
cursor: lenient(point),
selection: lenient(selection)
})
const peerState = v.object({
user: v.object({ name: lenient(name), color: lenient(color) }),
cursor: lenient(point),
selection: lenient(selection),
agents: v.fallback(
v.optional(
v.pipe(v.array(v.fallback(v.nullable(agent), null)), v.maxLength(MAX_AGENTS_PER_PEER))
),
[]
)
})
/** Validate a peer's awareness state; awareness comes from other people's browsers. */
export function parsePeer(clientId: number, state: unknown): RemotePeer | null {
const result = v.safeParse(peerState, state)
if (!result.success) return null
const { user, cursor, selection: selected, agents } = result.output
return {
clientId,
name: user.name ?? 'Anonymous',
color: user.color ?? PEER_COLORS[clientId % PEER_COLORS.length],
cursor,
selection: selected,
agents: (agents ?? []).flatMap((entry) => (entry ? [entry] : []))
}
}

24
src/app/presence/types.ts Normal file
View file

@ -0,0 +1,24 @@
export const AGENT_KINDS = ['chat', 'acp', 'harness', 'mcp'] as const
export type AgentKind = (typeof AGENT_KINDS)[number]
export const AGENT_STATUSES = ['thinking', 'editing', 'idle'] as const
export type AgentStatus = (typeof AGENT_STATUSES)[number]
export interface PresencePoint {
x: number
y: number
pageId: string
}
/** An agent as its owner publishes it: metadata only, never prompts or tool arguments. */
export interface AgentPresence {
id: string
/** A callsign, unique among the agents in a room, such as "Fern". */
name: string
kind: AgentKind
model?: string
status: AgentStatus
/** Where the agent last worked, derived from the nodes it touched. */
cursor?: PresencePoint
selection?: string[]
}

View file

@ -12,6 +12,7 @@ import { runPageId } from '@/app/ai/tools'
import { aiToolOverrides } from '@/app/ai/tools/preferences'
import * as figmaFactory from '@/app/automation/bridge/figma-factory'
import { createEditorStore } from '@/app/editor/session/create'
import { presenceOf } from '@/app/presence/registry'
import { appPreferences } from '@/app/settings/preferences/store'
import { MOCK_USAGE } from '#tests/helpers/chat/usage'
@ -144,3 +145,22 @@ test('a run falls back to the page on screen when its page is deleted', async ()
expect(runPageId(store)).toBe(b)
})
})
test("shows the chat's agent where its tools work, then takes it off the canvas", async () => {
await withStore(async (store, { a }) => {
let during: ReturnType<typeof presenceOf>['agents']['value'] = []
await runMessage(store, [
rectangle,
async (editor) => {
during = presenceOf(editor).agents.value
}
])
const [shape] = store.graph.getChildren(a)
expect(during).toMatchObject([
{ kind: 'chat', status: 'editing', cursor: { x: shape?.x, y: shape?.y, pageId: a } }
])
expect(presenceOf(store).agents.value).toMatchObject([
{ name: during[0]?.name, status: 'idle', cursor: undefined }
])
})
})

View file

@ -0,0 +1,68 @@
import 'fake-indexeddb/auto'
import { afterEach, expect, test } from 'bun:test'
import { createEditorStore } from '@/app/editor/session/create'
import { addAgent, setOwnerColor, setPeers } from '@/app/presence/registry'
const stores: ReturnType<typeof createEditorStore>[] = []
afterEach(() => {
for (const store of stores.splice(0)) store.dispose()
})
function setup() {
const store = createEditorStore()
stores.push(store)
return { store, pageId: store.state.currentPageId, other: store.graph.addPage('Other').id }
}
const red = { r: 1, g: 0, b: 0, a: 1 }
test('draws active agents on their page, in their owner color', () => {
const { store, pageId, other } = setup()
const here = addAgent(store, 'chat')
const there = addAgent(store, 'chat')
here.update({ status: 'editing', cursor: { x: 5, y: 6, pageId } })
there.update({ status: 'editing', cursor: { x: 1, y: 1, pageId: other } })
setOwnerColor(store, red)
expect(store.state.presenceCursors).toEqual([
{ kind: 'agent', name: here.name, color: red, x: 5, y: 6, selection: undefined }
])
})
test('hides idle agents and gives each a different callsign', () => {
const { store, pageId } = setup()
const agents = Array.from({ length: 5 }, () => addAgent(store, 'chat'))
expect(new Set(agents.map((agent) => agent.name)).size).toBe(5)
agents[0]?.update({ status: 'idle', cursor: { x: 0, y: 0, pageId } })
expect(store.state.presenceCursors).toEqual([])
})
test('draws people and their agents with the person color', () => {
const { store, pageId } = setup()
setPeers(store, [
{
clientId: 3,
name: 'Ana',
color: red,
cursor: { x: 1, y: 2, pageId },
agents: [
{ id: 'a', name: 'Orbit', kind: 'mcp', status: 'thinking', cursor: { x: 3, y: 4, pageId } }
]
}
])
expect(
store.state.presenceCursors.map(({ kind, name, color }) => ({ kind, name, color }))
).toEqual([
{ kind: 'person', name: 'Ana', color: red },
{ kind: 'agent', name: 'Orbit', color: red }
])
})
test('follows the page on screen', async () => {
const { store, other } = setup()
addAgent(store, 'chat').update({ status: 'editing', cursor: { x: 1, y: 1, pageId: other } })
expect(store.state.presenceCursors).toHaveLength(0)
store.preparationController.acknowledgePresentation(Number.MAX_SAFE_INTEGER)
await store.switchPage(other)
expect(store.state.presenceCursors).toHaveLength(1)
})

View file

@ -0,0 +1,49 @@
import { describe, expect, test } from 'bun:test'
import { MAX_AGENTS_PER_PEER, parsePeer } from '@/app/presence/schema'
const user = { name: 'Ana', color: { r: 1, g: 0, b: 0, a: 1 } }
const agent = {
id: 'a1',
name: 'Fern',
kind: 'chat',
status: 'editing',
cursor: { x: 10, y: 20, pageId: '0:1' }
}
describe('parsePeer', () => {
test('reads a person with their agents', () => {
expect(
parsePeer(7, { user, cursor: { x: 1, y: 2, pageId: '0:1' }, agents: [agent] })
).toMatchObject({
clientId: 7,
name: 'Ana',
cursor: { x: 1, y: 2, pageId: '0:1' },
agents: [{ name: 'Fern', status: 'editing', cursor: { x: 10, y: 20 } }]
})
})
test('ignores a state without a user', () => {
expect(parsePeer(7, { cursor: { x: 1, y: 2, pageId: '0:1' } })).toBeNull()
})
test('drops invalid fields instead of the whole peer', () => {
const peer = parsePeer(7, {
user: { name: 'Ana', color: 'red' },
cursor: { x: Number.NaN, y: 2, pageId: '0:1' },
selection: 'everything',
agents: [agent, { ...agent, id: 'a2', kind: 'unknown' }, 42]
})
expect(peer).toMatchObject({ name: 'Ana', cursor: undefined, selection: undefined })
expect(peer?.agents.map((entry) => entry.id)).toEqual(['a1'])
})
test('bounds names and agent counts', () => {
const tooMany = Array.from({ length: MAX_AGENTS_PER_PEER + 1 }, (_, i) => ({
...agent,
id: `a${i}`
}))
expect(parsePeer(7, { user, agents: tooMany })?.agents).toEqual([])
expect(parsePeer(7, { user: { name: 'x'.repeat(200) } })?.name).toBe('Anonymous')
})
})

View file

@ -13,10 +13,16 @@ test('collaborator cursor names are shaped and long names end with an ellipsis',
store.state.panX = 0
store.state.panY = 0
store.state.zoom = 1
store.state.remoteCursors = [
{ name: 'Orbit', color: blue, x: 80, y: 80 },
{ name: 'Ava Yoder', color: red, x: 80, y: 160 },
{ name: 'A collaborator with a very long display name', color: blue, x: 80, y: 240 }
store.state.presenceCursors = [
{ kind: 'person', name: 'Orbit', color: blue, x: 80, y: 80 },
{ kind: 'person', name: 'Ava Yoder', color: red, x: 80, y: 160 },
{
kind: 'person',
name: 'A collaborator with a very long display name',
color: blue,
x: 80,
y: 240
}
]
store.requestRender()
})

View file

@ -0,0 +1,40 @@
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
test.use({ viewport: { width: 900, height: 700 } })
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
test('people and agents show arrows in their color, agents with an outlined sparkle label', async () => {
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageId = store.state.currentPageId
const card = store.graph.createNode('RECTANGLE', pageId, {
name: 'Card',
x: 220,
y: 220,
width: 160,
height: 100,
fills: [
{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.92, a: 1 }, opacity: 1, visible: true }
]
})
const ana = { r: 0.92, g: 0.34, b: 0.29, a: 1 }
const ben = { r: 0.2, g: 0.55, b: 0.95, a: 1 }
store.state.panX = 0
store.state.panY = 0
store.state.zoom = 1
store.state.presenceCursors = [
{ kind: 'person', name: 'Ana', color: ana, x: 120, y: 120 },
{ kind: 'agent', name: 'Fern', color: ana, x: 220, y: 220, selection: [card.id] },
{ kind: 'agent', name: 'Orbit', color: ben, x: 440, y: 180 }
]
store.clearSelection()
store.requestRender()
})
await editor.canvas.waitForRender()
editor.canvas.assertNoErrors()
expect(await editor.canvas.screenshotCanvasRegion(600, 420)).toMatchSnapshot(
'presence-cursors.png'
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -231,7 +231,7 @@ test('two browser peers synchronize editing, awareness, departure, and reconnect
})
await expect
.poll(() =>
host.page.evaluate(() => window.openPencil?.getStore?.().state.remoteCursors.length)
host.page.evaluate(() => window.openPencil?.getStore?.().state.presenceCursors.length)
)
.toBe(1)
@ -243,7 +243,7 @@ test('two browser peers synchronize editing, awareness, departure, and reconnect
.toBe(0)
await expect
.poll(() =>
host.page.evaluate(() => window.openPencil?.getStore?.().state.remoteCursors.length)
host.page.evaluate(() => window.openPencil?.getStore?.().state.presenceCursors.length)
)
.toBe(0)

View file

@ -0,0 +1,13 @@
import { expect, test } from '@playwright/test'
test('the canvas story starts CanvasKit and sizes its surface', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await page.goto('/iframe.html?id=vue-sdk-canvas-presence-cursors--room&viewMode=story')
const canvas = page.locator('canvas')
// A canvas keeps its 300×150 default until CanvasKit creates a surface at its CSS size.
await expect
.poll(() => canvas.evaluate((element: HTMLCanvasElement) => element.width))
.toBeGreaterThan(300)
expect(errors).toEqual([])
})