feat(app): follow people and agents from the toolbar avatars (#807)

* 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.

* feat(app): follow agents and list them in the share panel

Following lived in collab and only knew people. It moves into the presence registry with a person-or-agent target, so you can follow anyone's agent, including your own outside a room: the view goes to the agent's page and keeps its cursor centered, stays attached while it idles between replies, and lets go when it leaves. A new editor action, centerOn, replaces reading the canvas size from the DOM. Peer cursors keep their zoom so following a person still matches it.

The share panel lists everyone in the room with their agents, each with its status, page, and a follow toggle, and your own agents can be renamed inline. CollabPanel moves to collab-panel, and the two-browser relay helpers move out of the collab spec into tests/helpers/collab.

* 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.

* refactor(app): split the collaboration theme by component

One 18-slot theme served five components that each used a few slots,
with variants that applied to one slot. Avatars, the share button, the
presence list, page markers, and the mobile presence popover now have
their own themes, exported as tv() like the rest of src/theme.

* fix(app): truncate an agent's status before its name in the presence list

In a narrow share panel the status kept its width and the callsign
shrank to its first letter.

* feat(app): show presence and following on the toolbar avatars

The share popover held who was online, the Share button turned into a
Connected status, and following gave no feedback. Collaborators' avatars
now count their agents and list them on hover, your avatar holds your
agents and Leave room, and +N collects the rest. A frame and bar in the
followed color show whom you follow; your own input or Escape stops it.
The share popover keeps the room link, and Share keeps its label.

* fix(app): address review of following from the avatars

Escape stops following from the follow frame, once and not while typing
or after another control handled it. The frame shows the agent sparkle
as an icon. A test pins that following survives the target changing
pages mid-switch, and the test relay tolerates frames that are not JSON.

* fix(app): follow until you leave, wait for silent peers, reach agents by keyboard

Following records the page it put you on, so any other page change ends
it, even of a resting agent that would otherwise pull you back later. A
present peer without a cursor yet is waited for instead of dropped. The
room list button is always there, so keyboard users reach agents that
hover cards only show to the mouse. centerOn ignores points too far away
to represent, and the docs describe following from the avatars.

* fix(app): stop following on any zoom of yours, and keep follow switches from restarting

Keyboard and menu zoom changed the view without the pointer or wheel
input the frame listens for, so following kept going and later undid the
zoom; any viewport change following did not make now ends it. Cursor
updates no longer restart a switch already heading to the same page,
which could keep a slow page from committing. The room's connected
store is reactive, and agent rename starts on a single click.

* fix(app): never loop on a followed cursor's unknown page, and stop on zoom mid-switch

A peer's cursor could name a page this document lacks; following then
retried a switch that never moved, forever. Following now waits on such
cursors and only re-syncs after a switch that landed. A page switch
restores its viewport without viewport:changed, so your zoom during a
follow switch now stops following too.

* fix(app): cancel a loading follow switch when following stops

Stopping following, by Escape, your own input, or zoom, left a follow
page switch loading, which then took you to their page anyway. Stopping
now overtakes it with a switch to the page you are on.
This commit is contained in:
Danila Poyarkov 2026-10-04 00:09:40 +04:00 committed by GitHub
parent d2fd6db141
commit 82a600b512
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1646 additions and 420 deletions

View file

@ -16,6 +16,7 @@
### Added
- Follow collaborators and their AI agents from the avatars in the toolbar: an avatar counts that person's agents, hovering lists what each is doing and on which page, and clicking follows. A frame in their color and a “Following …” bar show whom you follow; Escape, clicking, scrolling, zooming, or switching pages stops it. Your own avatar renames your agents and leaves the room.
- 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.
- Hide, lock, and constrain layers in design JSX with `visible={false}`, `locked`, and `constraints={{ horizontal, vertical }}`, set italic text with `italic`, and describe strokes fully with `strokes`, `strokeWeights`, `strokeCap`, `strokeJoin`, and the node-level `dashPattern`. JSX export now writes these together with stacked, gradient, and image fills, every effect, absolutely positioned children, size limits, vertical text alignment, masks, and variable bindings, so rendering exported JSX reproduces them and `diff_jsx` reports changes to 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.
@ -35,6 +36,7 @@
### Changed
- Keep the Share button labeled Share while you are in a room, instead of turning it into a Connected status; a green dot on your avatar shows the room is live.
- Show Flatten, Outline text, and Outline stroke in the canvas context menu without icons, like every other item there.
- Keep an AI chat working on the page where it started when you switch to another page, instead of sending its next edits to whichever page is on screen. When the AI switches pages itself, your view follows.
- `openpencil://` and web `?node=` links select the layer on another page when the current page has none, switching to that page.

View file

@ -74,6 +74,22 @@ export function createViewportActions(ctx: EditorContext) {
emitViewportChanged(previous)
}
/** Put the world point (x, y) at the center of the viewport, at `zoom` (the current one by default). */
function centerOn(x: number, y: number, zoom = ctx.state.zoom) {
const previous = currentViewport()
const { width, height } = ctx.getViewportSize()
const nextZoom = Math.max(0.02, Math.min(256, zoom))
const panX = width / 2 - x * nextZoom
const panY = height / 2 - y * nextZoom
// Remote cursors are finite but unbounded; a point that overflows leaves the view alone.
if (!Number.isFinite(panX) || !Number.isFinite(panY)) return
ctx.state.zoom = nextZoom
ctx.state.panX = panX
ctx.state.panY = panY
ctx.requestRepaint()
emitViewportChanged(previous)
}
function zoomToFit() {
const nodes = ctx.graph.getChildren(ctx.state.currentPageId)
if (nodes.length === 0) return
@ -118,6 +134,7 @@ export function createViewportActions(ctx: EditorContext) {
applyZoom,
pan,
zoomToBounds,
centerOn,
zoomToFit,
zoomTo100,
zoomToLevel,

View file

@ -24,3 +24,19 @@ describe('zoomToLevel', () => {
expect(center(editor)).toEqual(before)
})
})
describe('centerOn', () => {
test('puts the point at the center at the given zoom', () => {
const editor = createEditor({ getViewportSize: () => VIEWPORT })
editor.centerOn(300, 200, 2)
expect(editor.state.zoom).toBe(2)
expect(center(editor)).toEqual({ x: 300, y: 200 })
})
test('leaves the view alone for a point too far away to represent', () => {
const editor = createEditor({ getViewportSize: () => VIEWPORT })
const before = { ...center(editor), zoom: editor.state.zoom }
editor.centerOn(1e308, 0, 256)
expect({ ...center(editor), zoom: editor.state.zoom }).toEqual(before)
})
})

View file

@ -24,7 +24,9 @@ Anyone with the link can join. The room stays active as long as at least one par
## Follow Mode
Click a collaborator's avatar in the top bar to follow their viewport. Your canvas pans and zooms to match their view. Click again to stop following.
Click a collaborator's avatar in the top bar to follow their viewport. Your canvas pans and zooms to match their view, and a frame in their color with a “Following …” bar shows whom you follow. Click the avatar again, press <kbd>Esc</kbd>, or click, scroll, zoom, or switch pages yourself to stop.
An avatar counts the agents that person runs. Hover over it to see each agent, what it is doing, and on which page, and click **Follow** next to an agent to keep the page and layers it is editing in view; following continues between its replies and stops when it leaves. The button after the avatars lists everyone in the room with their agents, and works from the keyboard. Your own avatar lists your agents — click one to rename it — and has **Leave room**.
## How It Works

View file

@ -1,7 +1,5 @@
{
"inThisRoom": "In diesem Raum",
"followingPeerStop": "Folge {name} (zum Stoppen klicken)",
"clickToFollowPeer": "Klicken, um {name} zu folgen",
"yourName": "Dein Name",
"enterYourName": "Deinen Namen eingeben",
"shareThisFile": "Diese Datei teilen",
@ -13,5 +11,17 @@
"pasteRoomLinkOrId": "Raumlink oder ID einfügen",
"connected": "Verbunden",
"disconnect": "Verbindung trennen",
"share": "Teilen"
"share": "Teilen",
"follow": "{name} folgen",
"stopFollowing": "{name} nicht mehr folgen",
"followingPerson": "Du folgst {name}",
"followingAgent": "Du folgst {agent} ({owner})",
"stopFollowingShort": "Nicht mehr folgen",
"leaveRoom": "Raum verlassen",
"morePeople": "{count} weitere",
"renameAgent": "{name} umbenennen",
"agentName": "Agentenname",
"agentThinking": "Denkt nach",
"agentEditing": "Bearbeitet",
"agentIdle": "Inaktiv"
}

View file

@ -1,7 +1,5 @@
{
"inThisRoom": "En esta sala",
"followingPeerStop": "Siguiendo a {name} (haz clic para detener)",
"clickToFollowPeer": "Haz clic para seguir a {name}",
"yourName": "Tu nombre",
"enterYourName": "Introduce tu nombre",
"shareThisFile": "Compartir este archivo",
@ -13,5 +11,17 @@
"pasteRoomLinkOrId": "Pega el enlace o ID de la sala",
"connected": "Conectado",
"disconnect": "Desconectar",
"share": "Compartir"
"share": "Compartir",
"follow": "Seguir a {name}",
"stopFollowing": "Dejar de seguir a {name}",
"followingPerson": "Siguiendo a {name}",
"followingAgent": "Siguiendo a {agent} ({owner})",
"stopFollowingShort": "Dejar de seguir",
"leaveRoom": "Salir de la sala",
"morePeople": "{count} más",
"renameAgent": "Renombrar {name}",
"agentName": "Nombre del agente",
"agentThinking": "Pensando",
"agentEditing": "Editando",
"agentIdle": "Inactivo"
}

View file

@ -1,7 +1,5 @@
{
"inThisRoom": "Dans cette salle",
"followingPeerStop": "Suit {name} (cliquer pour arrêter)",
"clickToFollowPeer": "Cliquer pour suivre {name}",
"yourName": "Votre nom",
"enterYourName": "Entrez votre nom",
"shareThisFile": "Partager ce fichier",
@ -13,5 +11,17 @@
"pasteRoomLinkOrId": "Collez le lien ou l’ID de la salle",
"connected": "Connecté",
"disconnect": "Déconnecter",
"share": "Partager"
"share": "Partager",
"follow": "Suivre {name}",
"stopFollowing": "Ne plus suivre {name}",
"followingPerson": "Vous suivez {name}",
"followingAgent": "Vous suivez {agent} ({owner})",
"stopFollowingShort": "Ne plus suivre",
"leaveRoom": "Quitter la salle",
"morePeople": "{count} de plus",
"renameAgent": "Renommer {name}",
"agentName": "Nom de l’agent",
"agentThinking": "Réfléchit",
"agentEditing": "Modifie",
"agentIdle": "Inactif"
}

View file

@ -1,7 +1,5 @@
{
"inThisRoom": "In questa stanza",
"followingPeerStop": "Segui {name} (clic per interrompere)",
"clickToFollowPeer": "Fai clic per seguire {name}",
"yourName": "Il tuo nome",
"enterYourName": "Inserisci il tuo nome",
"shareThisFile": "Condividi questo file",
@ -13,5 +11,17 @@
"pasteRoomLinkOrId": "Incolla il link o l'ID della stanza",
"connected": "Connesso",
"disconnect": "Disconnetti",
"share": "Condividi"
"share": "Condividi",
"follow": "Segui {name}",
"stopFollowing": "Smetti di seguire {name}",
"followingPerson": "Stai seguendo {name}",
"followingAgent": "Stai seguendo {agent} ({owner})",
"stopFollowingShort": "Smetti di seguire",
"leaveRoom": "Esci dalla stanza",
"morePeople": "Altri {count}",
"renameAgent": "Rinomina {name}",
"agentName": "Nome dell’agente",
"agentThinking": "Sta pensando",
"agentEditing": "Sta modificando",
"agentIdle": "Inattivo"
}

View file

@ -1,7 +1,5 @@
{
"inThisRoom": "このルーム内",
"followingPeerStop": "{name} をフォロー中 (クリックして停止)",
"clickToFollowPeer": "クリックして {name} をフォロー",
"yourName": "お名前",
"enterYourName": "名前を入力してください",
"shareThisFile": "このファイルを共有",
@ -13,5 +11,17 @@
"pasteRoomLinkOrId": "ルームのリンクまたはIDを貼り付け",
"connected": "接続済み",
"disconnect": "切断",
"share": "共有"
"share": "共有",
"follow": "{name}をフォロー",
"stopFollowing": "{name}のフォローを解除",
"followingPerson": "{name}をフォロー中",
"followingAgent": "{agent}({owner})をフォロー中",
"stopFollowingShort": "フォローを解除",
"leaveRoom": "ルームを退出",
"morePeople": "他{count}人",
"renameAgent": "{name}の名前を変更",
"agentName": "エージェント名",
"agentThinking": "考え中",
"agentEditing": "編集中",
"agentIdle": "待機中"
}

View file

@ -1,7 +1,5 @@
{
"inThisRoom": "W tym pokoju",
"followingPeerStop": "Obserwujesz {name} (kliknij, aby zatrzymać)",
"clickToFollowPeer": "Kliknij, aby obserwować {name}",
"yourName": "Twoje imię",
"enterYourName": "Wpisz swoje imię",
"shareThisFile": "Udostępnij ten plik",
@ -13,5 +11,17 @@
"pasteRoomLinkOrId": "Wklej link lub ID pokoju",
"connected": "Połączono",
"disconnect": "Rozłącz",
"share": "Udostępnij"
"share": "Udostępnij",
"follow": "Obserwuj {name}",
"stopFollowing": "Przestań obserwować {name}",
"followingPerson": "Obserwujesz: {name}",
"followingAgent": "Obserwujesz: {agent} ({owner})",
"stopFollowingShort": "Przestań obserwować",
"leaveRoom": "Opuść pokój",
"morePeople": "{count} więcej",
"renameAgent": "Zmień nazwę {name}",
"agentName": "Nazwa agenta",
"agentThinking": "Myśli",
"agentEditing": "Edytuje",
"agentIdle": "Bezczynny"
}

View file

@ -1,7 +1,5 @@
{
"inThisRoom": "В этой комнате",
"followingPeerStop": "Следите за {name} (нажмите, чтобы остановить)",
"clickToFollowPeer": "Нажмите, чтобы следить за {name}",
"yourName": "Ваше имя",
"enterYourName": "Введите ваше имя",
"shareThisFile": "Поделиться файлом",
@ -13,5 +11,17 @@
"pasteRoomLinkOrId": "Вставьте ссылку на комнату или ID",
"connected": "Подключено",
"disconnect": "Отключиться",
"share": "Поделиться"
"share": "Поделиться",
"follow": "Следовать за {name}",
"stopFollowing": "Перестать следовать за {name}",
"followingPerson": "Вы следуете за {name}",
"followingAgent": "Вы следуете за {agent} ({owner})",
"stopFollowingShort": "Перестать следовать",
"leaveRoom": "Покинуть комнату",
"morePeople": "Ещё {count}",
"renameAgent": "Переименовать {name}",
"agentName": "Имя агента",
"agentThinking": "Думает",
"agentEditing": "Редактирует",
"agentIdle": "Неактивен"
}

View file

@ -1,7 +1,5 @@
{
"inThisRoom": "此房间中",
"followingPeerStop": "正在跟随 {name}(点击停止)",
"clickToFollowPeer": "点击跟随 {name}",
"yourName": "你的名字",
"enterYourName": "请输入你的名字",
"shareThisFile": "分享此文件",
@ -13,5 +11,17 @@
"pasteRoomLinkOrId": "粘贴房间链接或 ID",
"connected": "已连接",
"disconnect": "断开连接",
"share": "分享"
"share": "分享",
"follow": "跟随 {name}",
"stopFollowing": "停止跟随 {name}",
"followingPerson": "正在跟随 {name}",
"followingAgent": "正在跟随 {agent}({owner})",
"stopFollowingShort": "停止跟随",
"leaveRoom": "离开房间",
"morePeople": "还有 {count} 人",
"renameAgent": "重命名 {name}",
"agentName": "智能体名称",
"agentThinking": "思考中",
"agentEditing": "编辑中",
"agentIdle": "空闲"
}

View file

@ -4,8 +4,6 @@ import { i18n } from '#vue/i18n/create'
export const collaborationMessageDefaults = {
inThisRoom: 'In this room',
followingPeerStop: params('Following {name} (click to stop)'),
clickToFollowPeer: params('Click to follow {name}'),
yourName: 'Your name',
enterYourName: 'Enter your name',
shareThisFile: 'Share this file',
@ -17,7 +15,19 @@ export const collaborationMessageDefaults = {
pasteRoomLinkOrId: 'Paste room link or ID',
connected: 'Connected',
disconnect: 'Disconnect',
share: 'Share'
share: 'Share',
follow: params('Follow {name}'),
stopFollowing: params('Stop following {name}'),
followingPerson: params('Following {name}'),
followingAgent: params('Following {agent} ({owner})'),
stopFollowingShort: 'Stop following',
leaveRoom: 'Leave room',
morePeople: params('{count} more'),
renameAgent: params('Rename {name}'),
agentName: 'Agent name',
agentThinking: 'Thinking',
agentEditing: 'Editing',
agentIdle: 'Idle'
} as const
export const collaborationMessages = i18n('collaboration', collaborationMessageDefaults)

View file

@ -1,23 +1,10 @@
import { ref } from 'vue'
import type * as awarenessProtocol from 'y-protocols/awareness'
import { randomIndex } from '@open-pencil/core/random'
import type { EditorStore } from '@/app/editor/active-store'
import { parsePeer } from '@/app/presence/schema'
import { ROOM_ID_CHARS, ROOM_ID_LENGTH } from '@/constants'
import type { RemotePeer } from './types'
type Awareness = awarenessProtocol.Awareness
type CursorState = {
x: number
y: number
pageId: string
zoom?: number
}
/** People in the room other than us, validated: awareness comes from other browsers. */
export function buildRemotePeers(
states: Map<number, Record<string, unknown>>,
@ -28,46 +15,6 @@ export function buildRemotePeers(
)
}
export function createFollowActions(
getStore: () => EditorStore,
getAwareness: () => Awareness | null
) {
const followingPeer = ref<number | null>(null)
function followPeer(clientId: number | null) {
followingPeer.value = clientId
}
function resetFollow() {
followingPeer.value = null
}
function tickFollow() {
const store = getStore()
const awareness = getAwareness()
if (!followingPeer.value || !awareness) return
const peerState = awareness.getStates().get(followingPeer.value)
if (!peerState?.cursor) {
followingPeer.value = null
return
}
const cursor = peerState.cursor as CursorState
if (cursor.pageId !== store.state.currentPageId) {
void store.switchPage(cursor.pageId)
}
const canvas = document.querySelector('canvas')
if (!canvas) return
if (cursor.zoom) store.state.zoom = cursor.zoom
const cw = canvas.width / devicePixelRatio
const ch = canvas.height / devicePixelRatio
store.state.panX = cw / 2 - cursor.x * store.state.zoom
store.state.panY = ch / 2 - cursor.y * store.state.zoom
store.requestRender()
}
return { followingPeer, followPeer, resetFollow, tickFollow }
}
export function generateRoomId(): string {
let result = ''
for (let i = 0; i < ROOM_ID_LENGTH; i++) {

View file

@ -1,4 +1,4 @@
import type { Ref } from 'vue'
import { shallowRef, type Ref, type ShallowRef } from 'vue'
import { IndexeddbPersistence } from 'y-indexeddb'
import * as awarenessProtocol from 'y-protocols/awareness'
import type { Awareness } from 'y-protocols/awareness'
@ -22,7 +22,8 @@ export type CollabRuntime = {
yimages: Y.Map<Uint8Array> | null
room: CollabRoomTransport | null
persistence: IndexeddbPersistence | null
connectedStore: EditorStore | null
/** Reactive, so what depends on the room's document follows connects and disconnects. */
connectedStore: ShallowRef<EditorStore | null>
suppressGraphSync: boolean
suppressYjsEvents: boolean
unbindGraphEvents: (() => void) | null
@ -37,7 +38,6 @@ type ConnectCollabSessionOptions = {
store: EditorStore
disconnect: () => void
updatePeersList: () => void
tickFollow: () => void
broadcastAwareness: () => void
applyYjsToGraph: (events: Y.YEvent<Y.Map<unknown>>[]) => void
syncNodeToYjs: (nodeId: string) => void
@ -48,11 +48,9 @@ type CollabConnectionActionsOptions = {
state: Ref<CollabState>
getStore: () => EditorStore
updatePeersList: () => void
tickFollow: () => void
broadcastAwareness: () => void
applyYjsToGraph: (events: Y.YEvent<Y.Map<unknown>>[]) => void
syncNodeToYjs: (nodeId: string) => void
resetFollow: () => void
}
type CollabSessionResources = {
@ -64,7 +62,6 @@ type CollabSessionResources = {
unbindGraphEvents: (() => void) | null
stopZoomWatch: (() => void) | null
stopAgentSync: (() => void) | null
resetFollow: () => void
}
export function createCollabRuntime(): CollabRuntime {
@ -75,7 +72,7 @@ export function createCollabRuntime(): CollabRuntime {
yimages: null,
room: null,
persistence: null,
connectedStore: null,
connectedStore: shallowRef<EditorStore | null>(null),
suppressGraphSync: false,
suppressYjsEvents: false,
unbindGraphEvents: null,
@ -99,11 +96,9 @@ export function createCollabConnectionActions({
state,
getStore,
updatePeersList,
tickFollow,
broadcastAwareness,
applyYjsToGraph,
syncNodeToYjs,
resetFollow
syncNodeToYjs
}: CollabConnectionActionsOptions) {
function connect(roomId: string) {
connectCollabSession({
@ -113,7 +108,6 @@ export function createCollabConnectionActions({
store: getStore(),
disconnect,
updatePeersList,
tickFollow,
broadcastAwareness,
applyYjsToGraph,
syncNodeToYjs
@ -121,7 +115,7 @@ export function createCollabConnectionActions({
}
function disconnect() {
const store = runtime.connectedStore ?? getStore()
const store = runtime.connectedStore.value ?? getStore()
disposeCollabSessionResources({
store,
room: runtime.room,
@ -130,8 +124,7 @@ export function createCollabConnectionActions({
ydoc: runtime.ydoc,
unbindGraphEvents: runtime.unbindGraphEvents,
stopZoomWatch: runtime.stopZoomWatch,
stopAgentSync: runtime.stopAgentSync,
resetFollow
stopAgentSync: runtime.stopAgentSync
})
resetCollabRuntime(runtime)
resetCollabConnectionState(state)
@ -160,14 +153,13 @@ export function connectCollabSession({
store,
disconnect,
updatePeersList,
tickFollow,
broadcastAwareness,
applyYjsToGraph,
syncNodeToYjs
}: ConnectCollabSessionOptions) {
if (runtime.room) disconnect()
runtime.connectedStore = store
runtime.connectedStore.value = store
state.value.roomId = roomId
runtime.ydoc = new Y.Doc()
runtime.awareness = new awarenessProtocol.Awareness(runtime.ydoc)
@ -175,10 +167,8 @@ export function connectCollabSession({
runtime.yimages = runtime.ydoc.getMap('images')
runtime.persistence = new IndexeddbPersistence(`op-room-${roomId}`, runtime.ydoc)
runtime.awareness.on('change', () => {
updatePeersList()
tickFollow()
})
// Peer updates also move the view of anyone following them.
runtime.awareness.on('change', updatePeersList)
registerYjsObservers({
store,
@ -229,7 +219,7 @@ export function resetCollabRuntime(runtime: CollabRuntime) {
runtime.ydoc = null
runtime.ynodes = null
runtime.yimages = null
runtime.connectedStore = null
runtime.connectedStore.value = null
}
export function resetCollabConnectionState(state: Ref<CollabState>) {
@ -248,6 +238,5 @@ export function disposeCollabSessionResources(resources: CollabSessionResources)
}
resources.ydoc?.destroy()
resources.stopAgentSync?.()
resources.resetFollow()
setPeers(resources.store, [])
}

View file

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

View file

@ -1,7 +1,7 @@
import { tryOnScopeDispose, useLocalStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import { createFollowActions, generateRoomId } from '@/app/collab/awareness'
import { generateRoomId } from '@/app/collab/awareness'
import { createLocalAwarenessActions } from '@/app/collab/local-awareness'
import {
createCollabConnectionActions,
@ -11,6 +11,8 @@ import {
import { DEFAULT_COLLAB_STATE, type CollabState, type RemotePeer } from '@/app/collab/types'
import { createYjsGraphSync } from '@/app/collab/yjs-sync'
import type { EditorStore } from '@/app/editor/active-store'
import { follow, presenceOf } from '@/app/presence/registry'
import type { FollowTarget } from '@/app/presence/types'
export { COLLAB_KEY, useCollabInjected } from '@/app/collab/context'
export { DEFAULT_COLLAB_STATE }
@ -23,11 +25,11 @@ export function useCollab(storeOrGetter: EditorStore | (() => EditorStore)) {
const state = ref<CollabState>(createInitialCollabState(storedName.value))
const runtime = createCollabRuntime()
const remotePeers = computed(() => state.value.peers)
const getActiveStore = () => runtime.connectedStore ?? getStore()
const getActiveStore = () => runtime.connectedStore.value ?? getStore()
const { followingPeer, followPeer, resetFollow, tickFollow } = createFollowActions(
getActiveStore,
() => runtime.awareness
const following = computed(() => presenceOf(getActiveStore()).following.value)
const followingPeer = computed(() =>
following.value?.kind === 'person' ? following.value.clientId : null
)
const { broadcastAwareness, updateCursor, updateSelection, updatePeersList, setLocalName } =
createLocalAwarenessActions({
@ -51,11 +53,9 @@ export function useCollab(storeOrGetter: EditorStore | (() => EditorStore)) {
state,
getStore,
updatePeersList,
tickFollow,
broadcastAwareness,
applyYjsToGraph,
syncNodeToYjs,
resetFollow
syncNodeToYjs
})
function shareCurrentDoc(): string {
@ -77,7 +77,9 @@ export function useCollab(storeOrGetter: EditorStore | (() => EditorStore)) {
updateCursor,
updateSelection,
setLocalName,
followPeer,
tickFollow
following,
follow: (target: FollowTarget | null) => follow(getActiveStore(), target),
followPeer: (clientId: number | null) =>
follow(getActiveStore(), clientId === null ? null : { kind: 'person', clientId })
}
}

View file

@ -0,0 +1,26 @@
import { useEventListener } from '@vueuse/core'
import { computed, type MaybeRefOrGetter } from 'vue'
import { useActiveEditorStoreRef } from '@/app/editor/active-store'
import { follow, followedLabel, presenceOf } from './registry'
/**
* Who the active document's view follows, for a canvas pane's frame and banner. Your own
* input on the canvas — a click, scrolling or zooming — stops following; the frame also
* stops it on Escape.
*/
export function useFollowView(area: MaybeRefOrGetter<HTMLElement | null>) {
// Presence belongs to the store object itself, not the forwarding proxy components get.
const storeRef = useActiveEditorStoreRef()
const label = computed(() => (storeRef.value ? followedLabel(storeRef.value) : null))
const stop = () => {
const store = storeRef.value
if (store && presenceOf(store).following.value) follow(store, null)
}
useEventListener(area, ['pointerdown', 'wheel'], stop, { capture: true, passive: true })
return { label, stop }
}

View file

@ -9,7 +9,8 @@ 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'
import { MAX_NAME_LENGTH } from './schema'
import type { AgentKind, AgentPresence, FollowTarget, PersonPoint } 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 }
@ -21,6 +22,14 @@ interface Presence {
peers: ShallowRef<readonly RemotePeer[]>
/** The local collaborator's color while in a room. */
ownerColor: ShallowRef<Color | null>
/** Who the viewport follows, if anyone. */
following: ShallowRef<FollowTarget | null>
/** The page following put the view on; landing anywhere else means you left. */
followPage: string | null
/** The page a follow switch is on its way to, so cursor updates do not restart it. */
switching: string | null
/** True while following centers the view; any other viewport change is yours. */
moving: boolean
}
const presences = new WeakMap<EditorStore, Presence>()
@ -31,10 +40,22 @@ export function presenceOf(store: EditorStore): Presence {
const presence: Presence = {
agents: shallowRef([]),
peers: shallowRef([]),
ownerColor: shallowRef(null)
ownerColor: shallowRef(null),
following: shallowRef(null),
followPage: null,
switching: null,
moving: false
}
presences.set(store, presence)
store.onEditorEvent('page:changed', () => refreshCursors(store))
store.onEditorEvent('page:changed', () => {
stopIfLeftFollowedPage(store)
refreshCursors(store)
})
// Zooming or fitting the view yourself, by any shortcut or menu, ends following. A page
// switch restores that page's viewport without this event, so only centering is ours.
store.onEditorEvent('viewport:changed', () => {
if (presence.following.value && !presence.moving) stopFollowing(store)
})
return presence
}
@ -68,6 +89,125 @@ export function refreshCursors(store: EditorStore): void {
)
]
store.requestRepaint()
keepFollowing(store)
}
/**
* Where the followed person or agent is: null when they left, idle while an agent rests or a
* person has not pointed anywhere yet.
*/
function followedPoint(store: EditorStore, target: FollowTarget): PersonPoint | 'idle' | null {
const { agents, peers } = presenceOf(store)
if (target.kind === 'person') {
const peer = peers.value.find((entry) => entry.clientId === target.clientId)
return peer ? (peer.cursor ?? 'idle') : null
}
const agent = [...agents.value, ...peers.value.flatMap((peer) => peer.agents)].find(
(entry) => entry.id === target.agentId
)
if (!agent) return null
return agent.status === 'idle' ? 'idle' : (agent.cursor ?? 'idle')
}
/** Move the view to the followed person or agent; stop when they are gone. */
function keepFollowing(store: EditorStore): void {
const presence = presenceOf(store)
const target = presence.following.value
if (!target) return
const point = followedPoint(store, target)
if (point === 'idle') return
if (!point) {
presence.following.value = null
return
}
// A peer's cursor can name a page this document does not have; wait as for a resting agent.
if (store.graph.getNode(point.pageId)?.type !== 'CANVAS') return
presence.followPage = point.pageId
if (point.pageId !== store.state.currentPageId) {
// Each switch cancels the one before it, so frequent cursor updates must not restart one
// already heading to this page; when it lands, follow wherever they went meanwhile.
if (presence.switching === point.pageId) return
const pageId = point.pageId
presence.switching = pageId
void store.switchPage(pageId).finally(() => {
if (presence.switching === pageId) presence.switching = null
if (store.state.currentPageId === pageId) keepFollowing(store)
})
return
}
presence.moving = true
try {
store.centerOn(point.x, point.y, point.zoom)
} finally {
presence.moving = false
}
}
/** A page change following did not make is yours, so it ends following, even of a resting agent. */
function stopIfLeftFollowedPage(store: EditorStore): void {
const presence = presenceOf(store)
if (presence.following.value && store.state.currentPageId !== presence.followPage) {
presence.following.value = null
}
}
/** Who the view follows, for showing it: their name, an agent's owner, and the color. */
export interface FollowedLabel {
kind: FollowTarget['kind']
name: string
/** For an agent, the person who runs it; undefined for our own agents. */
owner?: string
color: Color
}
export function followedLabel(store: EditorStore): FollowedLabel | null {
const { agents, peers, ownerColor, following } = presenceOf(store)
const target = following.value
if (!target) return null
if (target.kind === 'person') {
const peer = peers.value.find((entry) => entry.clientId === target.clientId)
return peer ? { kind: 'person', name: peer.name, color: peer.color } : null
}
const own = agents.value.find((agent) => agent.id === target.agentId)
if (own) return { kind: 'agent', name: own.name, color: ownerColor.value ?? SOLO_AGENT_COLOR }
for (const peer of peers.value) {
const agent = peer.agents.find((entry) => entry.id === target.agentId)
if (agent) return { kind: 'agent', name: agent.name, owner: peer.name, color: peer.color }
}
return null
}
/** Follow a person or an agent, or stop following with null. */
/** Stop following; a follow switch still loading is overtaken, so it does not land later. */
function stopFollowing(store: EditorStore): void {
const presence = presenceOf(store)
presence.following.value = null
if (presence.switching) {
presence.switching = null
void store.switchPage(store.state.currentPageId)
}
}
export function follow(store: EditorStore, target: FollowTarget | null): void {
if (!target) {
stopFollowing(store)
return
}
const presence = presenceOf(store)
presence.following.value = target
presence.followPage = store.state.currentPageId
keepFollowing(store)
}
/** Rename one of our agents; the new name reaches the room with our next awareness update. */
export function renameAgent(store: EditorStore, agentId: string, name: string): void {
const trimmed = name.trim().slice(0, MAX_NAME_LENGTH)
const presence = presenceOf(store)
if (!trimmed) return
presence.agents.value = presence.agents.value.map((agent) =>
agent.id === agentId ? { ...agent, name: trimmed } : agent
)
refreshCursors(store)
}
export function setPeers(store: EditorStore, peers: readonly RemotePeer[]): void {
@ -102,6 +242,7 @@ export function addAgent(store: EditorStore, kind: AgentKind, model?: string): A
status: 'idle'
}
presence.agents.value = [...presence.agents.value, agent]
const current = () => presence.agents.value.find((entry) => entry.id === agent.id)
const replace = (next: AgentPresence | null) => {
const agents = presence.agents.value
presence.agents.value = next
@ -111,10 +252,13 @@ export function addAgent(store: EditorStore, kind: AgentKind, model?: string): A
}
return {
id: agent.id,
name: agent.name,
// The owner may rename the agent.
get name() {
return current()?.name ?? agent.name
},
update: (patch) => {
const current = presence.agents.value.find((entry) => entry.id === agent.id)
if (current) replace({ ...current, ...patch })
const entry = current()
if (entry) replace({ ...entry, ...patch })
},
remove: () => replace(null)
}

View file

@ -8,25 +8,29 @@ 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
export const MAX_NAME_LENGTH = 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))
const name = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(MAX_NAME_LENGTH))
/** 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 personPoint = v.object({
...point.entries,
zoom: lenient(v.pipe(finite, v.minValue(0.02), v.maxValue(256)))
})
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))),
model: lenient(v.pipe(v.string(), v.maxLength(MAX_NAME_LENGTH))),
status: v.picklist(AGENT_STATUSES),
cursor: lenient(point),
selection: lenient(selection)
@ -34,7 +38,7 @@ const agent = v.object({
const peerState = v.object({
user: v.object({ name: lenient(name), color: lenient(color) }),
cursor: lenient(point),
cursor: lenient(personPoint),
selection: lenient(selection),
agents: v.fallback(
v.optional(

View file

@ -10,6 +10,14 @@ export interface PresencePoint {
pageId: string
}
/** A person's pointer, with the zoom they view it at so followers can match it. */
export interface PersonPoint extends PresencePoint {
zoom?: number
}
/** Who to keep in view: a person in the room, or any agent, ours or theirs. */
export type FollowTarget = { kind: 'person'; clientId: number } | { kind: 'agent'; agentId: string }
/** An agent as its owner publishes it: metadata only, never prompts or tool arguments. */
export interface AgentPresence {
id: string

View file

@ -1,54 +0,0 @@
<script setup lang="ts">
import { tv } from 'tailwind-variants'
import { colorToCSS } from '@open-pencil/scene-graph/color'
import { useI18n } from '@open-pencil/vue'
import { initials } from '@/app/shell/ui'
import { useCollabPanelContext } from '@/components/CollabPanel/context'
import Tip from '@/components/ui/overlay/Tip.vue'
import collaborationTheme from '@/theme/collaboration'
const collab = useCollabPanelContext()
const { common, collaboration: collaborationMessages } = useI18n()
const collaboration = tv(collaborationTheme)
const avatar = collaboration({ size: 'sm', bordered: true })
function peerAvatarClass(following: boolean) {
return collaboration({ size: 'sm', bordered: true, following }).avatar()
}
</script>
<template>
<div class="flex -space-x-1.5">
<Tip :label="`${collab.state.localName || common.you} (${common.youSuffix})`">
<div
data-test-id="collab-local-avatar"
:class="avatar.avatar()"
:style="{ background: colorToCSS(collab.state.localColor) }"
>
{{ initials(collab.state.localName || common.you) }}
</div>
</Tip>
<Tip
v-for="peer in collab.peers"
:key="peer.clientId"
:label="
collab.followingPeer === peer.clientId
? collaborationMessages.followingPeerStop({ name: peer.name })
: collaborationMessages.clickToFollowPeer({ name: peer.name })
"
>
<div
data-test-id="collab-peer-avatar"
:data-following="collab.followingPeer === peer.clientId || undefined"
:class="[peerAvatarClass(collab.followingPeer === peer.clientId), avatar.peerAvatar()]"
:style="{ background: colorToCSS(peer.color) }"
@click="collab.toggleFollowPeer(peer.clientId)"
>
{{ initials(peer.name) }}
</div>
</Tip>
</div>
</template>

View file

@ -1,15 +0,0 @@
<script setup lang="ts">
import CollabAvatarStack from '@/components/CollabPanel/CollabAvatarStack.vue'
import CollabSharePopover from '@/components/CollabPanel/CollabSharePopover.vue'
import { provideCollabPanel } from '@/components/CollabPanel/context'
provideCollabPanel()
</script>
<template>
<div class="flex w-full items-center justify-end gap-2">
<CollabAvatarStack />
<div class="flex-1" />
<CollabSharePopover />
</div>
</template>

View file

@ -7,7 +7,7 @@ import {
PopoverPortal,
PopoverRoot
} from 'reka-ui'
import { computed, onUnmounted, ref, watch, type Component } from 'vue'
import { computed, onUnmounted, ref, useTemplateRef, watch, type Component } from 'vue'
import IconLucidePanelBottom from '~icons/lucide/panel-bottom'
import IconLucidePanelLeft from '~icons/lucide/panel-left'
import IconLucidePanelRight from '~icons/lucide/panel-right'
@ -30,8 +30,10 @@ import { useCollabInjected } from '@/app/collab/use'
import { useEditorStore } from '@/app/editor/active-store'
import { useCanvasCollaborationAwareness } from '@/app/editor/canvas/collaboration-awareness'
import { createCanvasContextSelection } from '@/app/editor/canvas/context-selection'
import { useFollowView } from '@/app/presence/follow-view'
import { appRuntimeConfig } from '@/app/runtime/config'
import PreparationOverlay from '@/components/preparation/canvas/Overlay.vue'
import FollowFrame from '@/components/presence/FollowFrame.vue'
import CanvasMenu from './canvas/CanvasMenu.vue'
import CanvasLabelEditor from './canvas/labels/CanvasLabelEditor.vue'
@ -48,6 +50,7 @@ const sceneCanvasRef = ref<HTMLCanvasElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const isActivePane = computed(() => !paneId || store.activePaneId.value === paneId)
const followView = useFollowView(useTemplateRef<HTMLElement>('area'))
function activatePane() {
if (paneId) store.setActivePane(paneId)
@ -176,6 +179,7 @@ const cursor = computed(() => toolCursor(store.state.activeTool, cursorOverride.
<ContextMenuRoot :modal="false">
<ContextMenuTrigger as-child @contextmenu.capture="selectAtContextPoint">
<div
ref="area"
data-test-id="canvas-area"
:data-pane-id="paneId"
:data-active-pane="isActivePane ? 'true' : 'false'"
@ -255,6 +259,11 @@ const cursor = computed(() => toolCursor(store.state.activeTool, cursorOverride.
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<FollowFrame
v-if="isActivePane && followView.label.value"
:followed="followView.label.value"
@stop="followView.stop"
/>
<PreparationOverlay
v-if="store.state.preparation && store.state.preparation.kind !== 'font-retry'"
:preparation="store.state.preparation"

View file

@ -1,6 +1,5 @@
<script setup lang="ts">
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { colorToCSS } from '@open-pencil/scene-graph/color'
@ -8,14 +7,14 @@ import { initials } from '@/app/shell/ui'
import HudButton from '@/components/mobile-hud/HudButton.vue'
import { useMobileHudContext } from '@/components/MobileHud/context'
import AppButton from '@/components/ui/button/AppButton.vue'
import collaborationTheme from '@/theme/collaboration'
import { avatar } from '@/theme/collaboration/avatar'
import { presencePopover } from '@/theme/mobile/presence-popover'
const hud = useMobileHudContext()
const collaboration = tv(collaborationTheme)
const styles = collaboration({ size: 'md' })
const styles = presencePopover()
function peerAvatarClass(following: boolean) {
return collaboration({ size: 'md', following }).avatar()
return avatar({ size: 'md', following, interactive: true })
}
</script>
@ -23,7 +22,7 @@ function peerAvatarClass(following: boolean) {
<PopoverRoot v-if="hud.collabState.connected">
<PopoverTrigger as-child>
<HudButton :label="`Online: ${hud.onlineCount}`">
<template #leading><span :class="styles.presenceDot()" /></template>
<template #leading><span :class="styles.dot()" /></template>
</HudButton>
</PopoverTrigger>
<PopoverPortal>
@ -32,7 +31,7 @@ function peerAvatarClass(following: boolean) {
:side-offset="8"
side="bottom"
align="center"
:class="styles.presenceContent()"
:class="styles.content()"
>
<div class="mb-2 text-[11px] tracking-wider text-muted uppercase">
{{ hud.messages.inThisRoom }}
@ -40,7 +39,7 @@ function peerAvatarClass(following: boolean) {
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div
:class="styles.avatar()"
:class="avatar({ size: 'md' })"
:style="{ background: colorToCSS(hud.collabState.localColor) }"
>
{{ initials(hud.collabState.localName || 'You') }}
@ -59,7 +58,7 @@ function peerAvatarClass(following: boolean) {
@click="hud.toggleFollowPeer(peer.clientId)"
>
<div
:class="[peerAvatarClass(hud.followingPeer === peer.clientId), styles.peerAvatar()]"
:class="peerAvatarClass(hud.followingPeer === peer.clientId)"
:style="{ background: colorToCSS(peer.color) }"
>
{{ initials(peer.name) }}

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
import { useCollabPanelContext } from '@/components/collab-panel/context'
import PresenceAvatars from './PresenceAvatars.vue'
const collab = useCollabPanelContext()
</script>
<template>
<PresenceAvatars
:rows="collab.presenceRows"
:following="collab.following"
:connected="collab.state.connected"
@follow="collab.follow"
@rename="collab.renameLocalAgent"
@leave="collab.disconnect"
/>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import CollabAvatarStack from '@/components/collab-panel/CollabAvatarStack.vue'
import CollabSharePopover from '@/components/collab-panel/CollabSharePopover.vue'
import { provideCollabPanel } from '@/components/collab-panel/context'
provideCollabPanel()
</script>
<template>
<div class="flex w-full items-center justify-end gap-2">
<CollabAvatarStack />
<div class="flex-1" />
<CollabSharePopover />
</div>
</template>

View file

@ -1,14 +1,13 @@
<script setup lang="ts">
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'
import ConnectedRoom from '@/components/CollabPanel/ConnectedRoom.vue'
import { useCollabPanelContext } from '@/components/CollabPanel/context'
import JoinRoomPrompt from '@/components/CollabPanel/JoinRoomPrompt.vue'
import ShareOrJoinRoom from '@/components/CollabPanel/ShareOrJoinRoom.vue'
import ConnectedRoom from '@/components/collab-panel/ConnectedRoom.vue'
import { useCollabPanelContext } from '@/components/collab-panel/context'
import JoinRoomPrompt from '@/components/collab-panel/JoinRoomPrompt.vue'
import ShareOrJoinRoom from '@/components/collab-panel/ShareOrJoinRoom.vue'
import { usePopoverUI } from '@/components/ui/overlay/popover'
import collaborationTheme from '@/theme/collaboration'
import { shareButton } from '@/theme/collaboration/share-button'
const collab = useCollabPanelContext()
const cls = usePopoverUI({ content: 'z-50 w-72 p-3' })
@ -17,8 +16,6 @@ const connection = computed(() => {
if (collab.isJoining) return 'joining'
return 'idle'
})
const collaboration = tv(collaborationTheme)
const styles = computed(() => collaboration({ connection: connection.value }))
</script>
<template>
@ -27,16 +24,10 @@ const styles = computed(() => collaboration({ connection: connection.value }))
<button
data-test-id="collab-share-button"
:data-connection="connection"
:class="styles.shareButton()"
:class="shareButton({ connection })"
>
<icon-lucide-share-2 class="size-3.5" />
{{
collab.state.connected
? collab.messages.connected
: collab.isJoining
? collab.messages.joinRoom
: collab.messages.share
}}
{{ collab.isJoining ? collab.messages.joinRoom : collab.messages.share }}
</button>
</PopoverTrigger>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { selectTarget } from '@open-pencil/vue'
import { useCollabPanelContext } from '@/components/CollabPanel/context'
import { useCollabPanelContext } from '@/components/collab-panel/context'
import AppButton from '@/components/ui/button/AppButton.vue'
import AppInput from '@/components/ui/input/AppInput.vue'
@ -10,7 +10,7 @@ const collab = useCollabPanelContext()
<template>
<div class="mb-3 text-xs font-medium text-surface">{{ collab.messages.roomLink }}</div>
<div class="mb-3 flex items-center gap-1.5">
<div class="flex items-center gap-1.5">
<AppInput
:model-value="collab.shareURL"
readonly
@ -31,17 +31,4 @@ const collab = useCollabPanelContext()
{{ collab.copied ? 'Copied' : 'Copy' }}
</AppButton>
</div>
<div class="mb-2 text-xs font-medium text-surface">
{{ collab.peers.length + 1 }} {{ collab.peers.length === 0 ? 'person' : 'people' }} in this room
</div>
<AppButton
variant="outline"
class="w-full"
data-test-id="collab-disconnect"
@click="collab.disconnect"
>
Disconnect
</AppButton>
</template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { useCollabPanelContext } from '@/components/CollabPanel/context'
import { useCollabPanelContext } from '@/components/collab-panel/context'
import AppButton from '@/components/ui/button/AppButton.vue'
import AppInput from '@/components/ui/input/AppInput.vue'

View file

@ -0,0 +1,64 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { expect, fn, userEvent, within } from 'storybook/test'
import type { FollowTarget } from '@/app/presence/types'
import { colors, room } from './examples/room'
import type { PresencePersonRow } from './presence'
import PresenceAvatars from './PresenceAvatars.vue'
const crowd: PresencePersonRow[] = [
...room,
{ clientId: 4, name: 'Cleo', color: colors.cleo, agents: [] },
{ clientId: 5, name: 'Eli', color: colors.ben, agents: [] },
{ clientId: 6, name: 'Fay', color: colors.ana, agents: [] }
]
type Args = { rows: PresencePersonRow[]; following: FollowTarget | null; connected: boolean }
const meta = {
title: 'Collaboration/Presence Avatars',
component: PresenceAvatars,
tags: ['autodocs'],
args: {
rows: room,
following: null,
connected: true,
onFollow: fn(),
onRename: fn(),
onLeave: fn()
},
render: (args) => ({
components: { PresenceAvatars },
setup: () => ({ args }),
template:
'<div class="flex justify-end bg-panel p-3 pb-48"><PresenceAvatars v-bind="args" /></div>'
})
} satisfies Meta<Args & { onFollow: () => void; onRename: () => void; onLeave: () => void }>
export default meta
type Story = StoryObj<typeof meta>
export const Room: Story = {}
export const FollowingSomeone: Story = {
args: { following: { kind: 'person', clientId: 2 } }
}
export const YourMenu: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByRole('button', { name: 'Dana (you)' }))
const menu = within(document.body)
await userEvent.click(await menu.findByRole('button', { name: 'Leave room' }))
await expect(args.onLeave).toHaveBeenCalledOnce()
}
}
export const ManyPeople: Story = {
args: { rows: crowd }
}
export const Alone: Story = {
args: { rows: room.slice(0, 1), connected: false }
}

View file

@ -0,0 +1,196 @@
<script setup lang="ts">
import {
HoverCardContent,
HoverCardPortal,
HoverCardRoot,
HoverCardTrigger,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { computed } from 'vue'
import { colorToCSS } from '@open-pencil/scene-graph/color'
import { useI18n } from '@open-pencil/vue'
import type { FollowTarget } from '@/app/presence/types'
import { initials } from '@/app/shell/ui'
import AppButton from '@/components/ui/button/AppButton.vue'
import { avatar } from '@/theme/collaboration/avatar'
import { presenceAvatars } from '@/theme/collaboration/presence-avatars'
import { isFollowing, type PresencePersonRow } from './presence'
import PresenceList from './PresenceList.vue'
/** How many collaborators the stack shows before "+N". */
const MAX_PEERS = 3
const {
rows,
following,
connected = false
} = defineProps<{
/** Ourselves first, then everyone else in the room. */
rows: PresencePersonRow[]
following: FollowTarget | null
connected?: boolean
}>()
const emit = defineEmits<{
follow: [target: FollowTarget | null]
rename: [agentId: string, name: string]
leave: []
}>()
const { common, collaboration: messages } = useI18n()
const ui = presenceAvatars()
const self = computed(() => rows[0])
const peers = computed(() => rows.slice(1, MAX_PEERS + 1))
const hidden = computed(() => Math.max(0, rows.length - 1 - MAX_PEERS))
function personTarget(clientId: number): FollowTarget {
return { kind: 'person', clientId }
}
function toggleFollow(clientId: number) {
const target = personTarget(clientId)
emit('follow', isFollowing(following, target) ? null : target)
}
</script>
<template>
<div data-test-id="collab-avatars" :class="ui.root()">
<PopoverRoot v-if="self">
<PopoverTrigger as-child>
<button
type="button"
data-test-id="collab-local-avatar"
:aria-label="`${self.name || common.you} (${common.youSuffix})`"
:class="ui.trigger()"
>
<span
:class="avatar({ bordered: true, interactive: true })"
:style="{ background: colorToCSS(self.color) }"
>
{{ initials(self.name || common.you) }}
</span>
<span
v-if="connected"
data-test-id="collab-live"
role="img"
:aria-label="messages.connected"
:class="ui.live()"
/>
<span
v-if="self.agents.length > 0"
:class="ui.badge()"
:style="{ color: colorToCSS(self.color) }"
>
<icon-lucide-sparkle :class="ui.badgeIcon()" />{{ self.agents.length }}
</span>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
data-test-id="collab-self-menu"
:class="ui.card()"
:side-offset="8"
align="end"
>
<PresenceList
:rows="[self]"
:following="following"
@follow="emit('follow', $event)"
@rename="(id, name) => emit('rename', id, name)"
/>
<AppButton
v-if="connected"
variant="outline"
:class="ui.leave()"
data-test-id="collab-leave-room"
@click="emit('leave')"
>
{{ messages.leaveRoom }}
</AppButton>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<HoverCardRoot v-for="peer in peers" :key="peer.clientId" :open-delay="250" :close-delay="150">
<HoverCardTrigger as-child>
<button
v-if="peer.clientId !== undefined"
type="button"
data-test-id="collab-peer-avatar"
:data-following="isFollowing(following, personTarget(peer.clientId)) || undefined"
:aria-label="
isFollowing(following, personTarget(peer.clientId))
? messages.stopFollowing({ name: peer.name })
: messages.follow({ name: peer.name })
"
:class="ui.trigger()"
@click="toggleFollow(peer.clientId)"
>
<span
:class="
avatar({
bordered: true,
interactive: true,
following: isFollowing(following, personTarget(peer.clientId))
})
"
:style="{ background: colorToCSS(peer.color) }"
>
{{ initials(peer.name) }}
</span>
<span
v-if="peer.agents.length > 0"
:class="ui.badge()"
:style="{ color: colorToCSS(peer.color) }"
>
<icon-lucide-sparkle :class="ui.badgeIcon()" />{{ peer.agents.length }}
</span>
</button>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent
data-test-id="collab-peer-card"
:class="ui.card()"
:side-offset="8"
align="end"
>
<PresenceList :rows="[peer]" :following="following" @follow="emit('follow', $event)" />
</HoverCardContent>
</HoverCardPortal>
</HoverCardRoot>
<!-- Everyone, by click or keyboard: hover cards are mouse-only. -->
<PopoverRoot v-if="rows.length > 1">
<PopoverTrigger as-child>
<button
type="button"
data-test-id="collab-everyone"
:aria-label="
hidden > 0 ? messages.morePeople({ count: String(hidden) }) : messages.inThisRoom
"
:class="ui.overflow()"
>
<template v-if="hidden > 0">+{{ hidden }}</template>
<icon-lucide-chevron-down v-else :class="ui.badgeIcon()" />
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent :class="ui.card()" :side-offset="8" align="end">
<PresenceList
:rows="rows"
:following="following"
@follow="emit('follow', $event)"
@rename="(id, name) => emit('rename', id, name)"
/>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</div>
</template>

View file

@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { expect, fn, userEvent, within } from 'storybook/test'
import type { FollowTarget } from '@/app/presence/types'
import { room } from './examples/room'
import type { PresencePersonRow } from './presence'
import PresenceList from './PresenceList.vue'
type Args = { rows: PresencePersonRow[]; following: FollowTarget | null }
const meta = {
title: 'Collaboration/Presence List',
component: PresenceList,
tags: ['autodocs'],
args: { rows: room, following: null, onFollow: fn(), onRename: fn() },
render: (args) => ({
components: { PresenceList },
setup: () => ({ args }),
template: '<div class="w-56 bg-panel p-3"><PresenceList v-bind="args" /></div>'
})
} satisfies Meta<Args & { onFollow: () => void; onRename: () => void }>
export default meta
type Story = StoryObj<typeof meta>
export const Room: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByRole('button', { name: 'Follow Orbit' }))
await expect(args.onFollow).toHaveBeenCalledWith({ kind: 'agent', agentId: 'orbit' })
}
}
export const FollowingAnAgent: Story = {
args: { following: { kind: 'agent', agentId: 'orbit' } },
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByRole('button', { name: 'Stop following Orbit' }))
await expect(args.onFollow).toHaveBeenCalledWith(null)
}
}
export const RenamingYourAgent: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByRole('button', { name: 'Rename Fern' }))
const input = canvas.getByRole('textbox', { name: 'Agent name' })
await userEvent.clear(input)
await userEvent.type(input, 'Juniper{Enter}')
await expect(args.onRename).toHaveBeenCalledWith('fern', 'Juniper')
}
}
export const Alone: Story = {
args: { rows: room.slice(0, 1) }
}

View file

@ -0,0 +1,115 @@
<script setup lang="ts">
import { colorToCSS } from '@open-pencil/scene-graph/color'
import { useI18n, useInlineRename } from '@open-pencil/vue'
import type { FollowTarget } from '@/app/presence/types'
import { initials } from '@/app/shell/ui'
import IconButton from '@/components/ui/button/IconButton.vue'
import { avatar } from '@/theme/collaboration/avatar'
import { presenceList } from '@/theme/collaboration/presence-list'
import { isFollowing, type PresenceAgentRow, type PresencePersonRow } from './presence'
const { rows, following } = defineProps<{
rows: PresencePersonRow[]
following: FollowTarget | null
}>()
const emit = defineEmits<{
follow: [target: FollowTarget | null]
rename: [agentId: string, name: string]
}>()
const { common, collaboration: messages } = useI18n()
const ui = presenceList()
const rename = useInlineRename<string>((agentId, name) => emit('rename', agentId, name))
/** The rename input sits inside v-for, so take it from a function ref rather than an array. */
function focusRename(element: unknown) {
if (element instanceof HTMLInputElement) void rename.focusInput(element)
}
function toggle(target: FollowTarget) {
emit('follow', isFollowing(following, target) ? null : target)
}
function status(agent: PresenceAgentRow): string {
const label = {
thinking: messages.value.agentThinking,
editing: messages.value.agentEditing,
idle: messages.value.agentIdle
}[agent.status]
// The page is a document name, not translated text, so a separator joins the two.
return agent.page ? `${label} · ${agent.page}` : label
}
function followLabel(name: string, target: FollowTarget): string {
return isFollowing(following, target)
? messages.value.stopFollowing({ name })
: messages.value.follow({ name })
}
</script>
<template>
<ul data-test-id="collab-presence-list" :class="ui.root()">
<li v-for="person in rows" :key="person.clientId ?? 'self'">
<div data-slot="person" :class="ui.person()">
<div :class="avatar()" :style="{ background: colorToCSS(person.color) }">
{{ initials(person.name || common.you) }}
</div>
<span :class="ui.name()">
{{
person.clientId === undefined
? `${person.name || common.you} (${common.youSuffix})`
: person.name
}}
</span>
<IconButton
v-if="person.clientId !== undefined"
:label="followLabel(person.name, { kind: 'person', clientId: person.clientId })"
:active="isFollowing(following, { kind: 'person', clientId: person.clientId })"
@click="toggle({ kind: 'person', clientId: person.clientId })"
>
<icon-lucide-locate-fixed class="size-3.5" />
</IconButton>
</div>
<ul v-if="person.agents.length > 0">
<li v-for="agent in person.agents" :key="agent.id" data-slot="agent" :class="ui.agent()">
<icon-lucide-sparkle
:class="ui.agentIcon()"
:style="{ color: colorToCSS(person.color) }"
/>
<input
v-if="rename.editingId.value === agent.id"
:ref="focusRename"
:aria-label="messages.agentName"
:class="ui.renameInput()"
:value="agent.name"
@blur="rename.commit(agent.id, $event)"
@keydown.stop="rename.onKeydown"
/>
<button
v-else-if="agent.renamable"
type="button"
:aria-label="messages.renameAgent({ name: agent.name })"
:class="ui.agentName()"
@click="rename.start(agent.id, agent.name)"
@keydown.enter.prevent="rename.start(agent.id, agent.name)"
>
{{ agent.name }}
</button>
<span v-else :class="ui.agentName()">{{ agent.name }}</span>
<span :class="ui.status()">{{ status(agent) }}</span>
<IconButton
:label="followLabel(agent.name, { kind: 'agent', agentId: agent.id })"
:active="isFollowing(following, { kind: 'agent', agentId: agent.id })"
@click="toggle({ kind: 'agent', agentId: agent.id })"
>
<icon-lucide-locate-fixed class="size-3.5" />
</IconButton>
</li>
</ul>
</li>
</ul>
</template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { useCollabPanelContext } from '@/components/CollabPanel/context'
import { useCollabPanelContext } from '@/components/collab-panel/context'
import AppButton from '@/components/ui/button/AppButton.vue'
import AppInput from '@/components/ui/input/AppInput.vue'

View file

@ -6,10 +6,15 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from '@open-pencil/vue'
import { DEFAULT_COLLAB_STATE, useCollabInjected } from '@/app/collab/use'
import { useActiveEditorStoreRef } from '@/app/editor/active-store'
import { useNotificationMessages } from '@/app/i18n/notifications'
import { presenceOf, renameAgent } from '@/app/presence/registry'
import type { FollowTarget } from '@/app/presence/types'
import { toast } from '@/app/shell/ui'
import { getShareURL } from '@/constants'
import { presenceRows as buildPresenceRows } from './presence'
function createCollabPanelContext() {
const route = useRoute()
const router = useRouter()
@ -26,7 +31,20 @@ function createCollabPanelContext() {
const popoverOpen = ref(!!pendingRoomId.value)
const state = computed(() => collab?.state.value ?? DEFAULT_COLLAB_STATE)
const peers = computed(() => collab?.remotePeers.value ?? [])
const followingPeer = computed(() => collab?.followingPeer.value ?? null)
const following = computed(() => collab?.following.value ?? null)
const storeRef = useActiveEditorStoreRef()
const presenceRows = computed(() => {
const store = storeRef.value
return buildPresenceRows(
{
name: state.value.localName,
color: state.value.localColor,
agents: store ? presenceOf(store).agents.value : []
},
peers.value,
(pageId) => store?.graph.getNode(pageId)?.name
)
})
const shareURL = computed(() => {
if (!state.value.roomId) return ''
return getShareURL(state.value.roomId)
@ -74,8 +92,12 @@ function createCollabPanelContext() {
void router.push('/')
}
function toggleFollowPeer(clientId: number) {
collab?.followPeer(followingPeer.value === clientId ? null : clientId)
function follow(target: FollowTarget | null) {
collab?.follow(target)
}
function renameLocalAgent(agentId: string, name: string) {
if (storeRef.value) renameAgent(storeRef.value, agentId, name)
}
return {
@ -86,15 +108,16 @@ function createCollabPanelContext() {
nameDraft,
popoverOpen,
state,
peers,
followingPeer,
following,
presenceRows,
shareURL,
isJoining,
copyLink,
share,
join,
disconnect,
toggleFollowPeer
follow,
renameLocalAgent
}
}

View file

@ -0,0 +1,27 @@
import type { PresencePersonRow } from '../presence'
/** Sample collaborators for presence stories: you, then Ana with two agents, then Ben. */
export const colors = {
ana: { r: 0.92, g: 0.34, b: 0.29, a: 1 },
ben: { r: 0.2, g: 0.55, b: 0.95, a: 1 },
cleo: { r: 0.6, g: 0.36, b: 0.86, a: 1 },
you: { r: 0.3, g: 0.7, b: 0.45, a: 1 }
}
export const room: PresencePersonRow[] = [
{
name: 'Dana',
color: colors.you,
agents: [{ id: 'fern', name: 'Fern', status: 'editing', page: 'Checkout', renamable: true }]
},
{
clientId: 2,
name: 'Ana',
color: colors.ana,
agents: [
{ id: 'orbit', name: 'Orbit', status: 'thinking', page: 'Cover', renamable: false },
{ id: 'pixel', name: 'Pixel', status: 'idle', renamable: false }
]
},
{ clientId: 3, name: 'Ben', color: colors.ben, agents: [] }
]

View file

@ -0,0 +1,59 @@
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { RemotePeer } from '@/app/collab/types'
import type { AgentPresence, AgentStatus, FollowTarget } from '@/app/presence/types'
export interface PresenceAgentRow {
id: string
name: string
status: AgentStatus
/** The page the agent works on, when it is working. */
page?: string
renamable: boolean
}
export interface PresencePersonRow {
/** Undefined for ourselves, who cannot be followed. */
clientId?: number
name: string
color: Color
agents: PresenceAgentRow[]
}
function agentRows(
agents: readonly AgentPresence[],
pageName: (pageId: string) => string | undefined,
renamable: boolean
): PresenceAgentRow[] {
return agents.map((agent) => ({
id: agent.id,
name: agent.name,
status: agent.status,
page: agent.status !== 'idle' && agent.cursor ? pageName(agent.cursor.pageId) : undefined,
renamable
}))
}
/** Ourselves first, then everyone else in the room, each with the agents they run. */
export function presenceRows(
self: { name: string; color: Color; agents: readonly AgentPresence[] },
peers: readonly RemotePeer[],
pageName: (pageId: string) => string | undefined
): PresencePersonRow[] {
return [
{ name: self.name, color: self.color, agents: agentRows(self.agents, pageName, true) },
...peers.map((peer) => ({
clientId: peer.clientId,
name: peer.name,
color: peer.color,
agents: agentRows(peer.agents, pageName, false)
}))
]
}
export function isFollowing(following: FollowTarget | null, target: FollowTarget): boolean {
if (!following || following.kind !== target.kind) return false
return following.kind === 'person'
? target.kind === 'person' && following.clientId === target.clientId
: target.kind === 'agent' && following.agentId === target.agentId
}

View file

@ -12,7 +12,7 @@ import { resolvedAppTheme } from '@/app/shell/theme'
import { activeTab } from '@/app/tabs'
import BrandMark from '@/components/brand/BrandMark.vue'
import CanvasSplitRoot from '@/components/canvas/CanvasSplitRoot.vue'
import CollabPanel from '@/components/CollabPanel/CollabPanel.vue'
import CollabPanel from '@/components/collab-panel/CollabPanel.vue'
import EditorCanvas from '@/components/EditorCanvas.vue'
import LayersPanel from '@/components/LayersPanel.vue'
import MobileDrawer from '@/components/MobileDrawer.vue'

View file

@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { expect, fn, userEvent, within } from 'storybook/test'
import type { FollowedLabel } from '@/app/presence/registry'
import FollowFrame from './FollowFrame.vue'
const ana = { r: 0.92, g: 0.34, b: 0.29, a: 1 }
type Args = { followed: FollowedLabel; onStop: () => void }
const meta = {
title: 'Collaboration/Follow Frame',
component: FollowFrame,
args: { followed: { kind: 'person', name: 'Ana', color: ana }, onStop: fn() },
render: (args) => ({
components: { FollowFrame },
setup: () => ({ args }),
template: '<div class="relative h-64 w-[480px] bg-canvas"><FollowFrame v-bind="args" /></div>'
})
} satisfies Meta<Args>
export default meta
type Story = StoryObj<Meta<Args>>
export const FollowingAPerson: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await expect(canvas.getByRole('status')).toHaveTextContent('Following Ana')
await userEvent.click(canvas.getByRole('button', { name: 'Stop following' }))
await expect(args.onStop).toHaveBeenCalledOnce()
}
}
export const FollowingTheirAgent: Story = {
args: { followed: { kind: 'agent', name: 'Orbit', owner: 'Ana', color: ana } }
}

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed } from 'vue'
import { colorToCSS } from '@open-pencil/scene-graph/color'
import { useI18n } from '@open-pencil/vue'
import type { FollowedLabel } from '@/app/presence/registry'
import { isEditing } from '@/app/shell/keyboard/focus'
import { followFrame } from '@/theme/collaboration/follow-frame'
const { followed } = defineProps<{ followed: FollowedLabel }>()
const emit = defineEmits<{ stop: [] }>()
const { collaboration: messages } = useI18n()
const ui = followFrame()
const color = computed(() => colorToCSS(followed.color))
const text = computed(() =>
followed.owner
? messages.value.followingAgent({ agent: followed.name, owner: followed.owner })
: messages.value.followingPerson({ name: followed.name })
)
// Escape stops following, unless something else took it or you are typing.
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if (event.key === 'Escape' && !event.defaultPrevented && !isEditing(event)) emit('stop')
})
</script>
<template>
<div data-test-id="follow-frame" :class="ui.root()" :style="{ borderColor: color }">
<div role="status" :class="ui.bar()" :style="{ background: color }">
<icon-lucide-sparkle v-if="followed.kind === 'agent'" :class="ui.icon()" />
<span :class="ui.label()">{{ text }}</span>
<button type="button" data-test-id="follow-stop" :class="ui.stop()" @click="emit('stop')">
{{ messages.stopFollowingShort }}
</button>
</div>
</div>
</template>

View file

@ -1,54 +0,0 @@
const collaborationTheme = {
slots: {
avatar:
'flex shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white',
peerAvatar: 'cursor-pointer transition-all',
shareButton:
'flex h-7 cursor-pointer items-center gap-1.5 rounded border-none px-3 text-[11px] font-medium transition-colors outline-none focus-visible:ring-1 focus-visible:ring-accent',
presenceDot: 'size-2 rounded-full bg-green-500',
presenceContent: 'z-50 w-56 rounded-xl bg-panel p-3 shadow-[0_8px_30px_rgb(0_0_0/0.4)]',
peerRow:
'flex cursor-pointer items-center gap-2 rounded-md px-0.5 py-0.5 outline-none select-none active:bg-hover focus-visible:ring-1 focus-visible:ring-accent'
},
variants: {
following: {
true: { avatar: 'ring-2 ring-white/40' },
false: {}
},
bordered: {
true: { avatar: 'border-2 border-panel' },
false: {}
},
connection: {
idle: { shareButton: 'bg-accent text-white hover:bg-accent/90' },
joining: {
shareButton:
'animate-pulse motion-reduce:animate-none border border-[var(--color-warning-border)] bg-[var(--color-warning-bg)] text-[var(--color-warning-text)]'
},
connected: {
shareButton:
'bg-[var(--color-success-bg)] text-white hover:bg-[var(--color-success-bg-hover)]'
}
},
size: {
sm: { avatar: 'size-6' },
md: { avatar: 'size-7' }
}
},
compoundVariants: [
{
following: true,
bordered: true,
class: { avatar: 'border-white' }
}
],
defaultVariants: {
following: false,
bordered: false,
connection: 'idle' as const,
size: 'sm' as const
}
}
export type CollaborationTheme = typeof collaborationTheme
export default collaborationTheme

View file

@ -0,0 +1,15 @@
import { tv } from 'tailwind-variants'
/** A person's initials on their color, in the avatar stack, presence lists, and mobile HUD. */
export const avatar = tv({
base: 'flex shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white',
variants: {
size: { sm: 'size-6', md: 'size-7' },
bordered: { true: 'border-2 border-panel', false: '' },
following: { true: 'ring-2 ring-white/40', false: '' },
/** A collaborator's avatar, which follows them when clicked. */
interactive: { true: 'cursor-pointer transition-all', false: '' }
},
compoundVariants: [{ following: true, bordered: true, class: 'border-white' }],
defaultVariants: { size: 'sm', bordered: false, following: false, interactive: false }
})

View file

@ -0,0 +1,12 @@
import { tv } from 'tailwind-variants'
/** Marks a canvas that follows someone: a border in their color and a bar to stop. */
export const followFrame = tv({
slots: {
root: 'pointer-events-none absolute inset-0 z-30 border-2',
bar: 'pointer-events-auto absolute top-0 left-1/2 flex max-w-[calc(100%-1rem)] -translate-x-1/2 items-center gap-1.5 rounded-b-md py-1 pr-1 pl-2.5 text-[11px] font-medium text-white shadow-md',
icon: 'size-3 shrink-0',
label: 'min-w-0 truncate',
stop: 'shrink-0 cursor-pointer rounded bg-white/20 px-1.5 py-0.5 text-[11px] font-medium text-white outline-none hover:bg-white/30 focus-visible:ring-1 focus-visible:ring-white'
}
})

View file

@ -0,0 +1,19 @@
import { tv } from 'tailwind-variants'
/** The avatar stack in the toolbar: you, collaborators with their agent counts, then "+N". */
export const presenceAvatars = tv({
slots: {
root: 'flex items-center -space-x-1.5',
trigger:
'relative shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent',
badge:
'absolute -right-1.5 -bottom-1 flex items-center gap-px rounded-full border border-panel bg-panel px-0.5 text-[8px] leading-none font-semibold',
badgeIcon: 'size-2',
/** On your avatar while you are in a room. */
live: 'absolute -top-0.5 -right-0.5 size-2 rounded-full border border-panel bg-[var(--color-success-bg)]',
overflow:
'relative flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full border-2 border-panel bg-hover text-[9px] font-semibold text-surface outline-none focus-visible:ring-2 focus-visible:ring-accent',
card: 'z-50 w-60 rounded-xl bg-panel p-2 shadow-[0_8px_30px_rgb(0_0_0/0.4)]',
leave: 'mt-2 w-full'
}
})

View file

@ -0,0 +1,20 @@
import { tv } from 'tailwind-variants'
const NAME =
'min-w-0 cursor-default truncate text-left text-[11px] text-surface outline-none focus-visible:ring-1 focus-visible:ring-accent'
/** People in a room, each followed by their agents. */
export const presenceList = tv({
slots: {
root: 'flex flex-col gap-0.5',
person: 'flex h-7 min-w-0 items-center gap-2',
agent: 'flex h-7 min-w-0 items-center gap-2 pl-8',
agentIcon: 'size-3.5 shrink-0',
name: `${NAME} flex-1`,
// An agent's callsign keeps its width; the status beside it truncates first.
agentName: NAME,
status: 'min-w-0 flex-1 truncate text-right text-[10px] text-muted',
renameInput:
'h-6 min-w-0 flex-1 rounded border border-accent bg-input px-1.5 text-[11px] text-surface outline-none'
}
})

View file

@ -0,0 +1,15 @@
import { tv } from 'tailwind-variants'
export const shareButton = tv({
base: 'flex h-7 cursor-pointer items-center gap-1.5 rounded border-none px-3 text-[11px] font-medium transition-colors outline-none focus-visible:ring-1 focus-visible:ring-accent',
variants: {
connection: {
idle: 'bg-accent text-white hover:bg-accent/90',
joining:
'animate-pulse motion-reduce:animate-none border border-[var(--color-warning-border)] bg-[var(--color-warning-bg)] text-[var(--color-warning-text)]',
// Still a share action when connected; your avatar shows the room is live.
connected: 'bg-accent text-white hover:bg-accent/90'
}
},
defaultVariants: { connection: 'idle' }
})

View file

@ -0,0 +1,10 @@
import { tv } from 'tailwind-variants'
export const presencePopover = tv({
slots: {
dot: 'size-2 rounded-full bg-green-500',
content: 'z-50 w-56 rounded-xl bg-panel p-3 shadow-[0_8px_30px_rgb(0_0_0/0.4)]',
peerRow:
'flex cursor-pointer items-center gap-2 rounded-md px-0.5 py-0.5 outline-none select-none active:bg-hover focus-visible:ring-1 focus-visible:ring-accent'
}
})

View file

@ -1,8 +1,16 @@
import 'fake-indexeddb/auto'
import { afterEach, expect, test } from 'bun:test'
import { afterEach, expect, spyOn, test } from 'bun:test'
import { createEditorStore } from '@/app/editor/session/create'
import { addAgent, setOwnerColor, setPeers } from '@/app/presence/registry'
import {
addAgent,
follow,
followedLabel,
presenceOf,
renameAgent,
setOwnerColor,
setPeers
} from '@/app/presence/registry'
const stores: ReturnType<typeof createEditorStore>[] = []
afterEach(() => {
@ -66,3 +74,174 @@ test('follows the page on screen', async () => {
await store.switchPage(other)
expect(store.state.presenceCursors).toHaveLength(1)
})
/** In app tests the viewport falls back to 1920 × 1080. */
function centered(store: ReturnType<typeof createEditorStore>) {
const { panX, panY, zoom } = store.state
return { x: (960 - panX) / zoom, y: (540 - panY) / zoom }
}
test('following an agent takes the view to its page and keeps its cursor centered', async () => {
const { store, other } = setup()
store.preparationController.acknowledgePresentation(Number.MAX_SAFE_INTEGER)
const agent = addAgent(store, 'chat')
agent.update({ status: 'editing', cursor: { x: 300, y: 200, pageId: other } })
const switched = new Promise<string>((resolve) => {
store.onEditorEvent('page:changed', resolve)
})
follow(store, { kind: 'agent', agentId: agent.id })
expect(await switched).toBe(other)
expect(centered(store)).toEqual({ x: 300, y: 200 })
agent.update({ cursor: { x: 500, y: 100, pageId: other } })
expect(centered(store)).toEqual({ x: 500, y: 100 })
})
test('an idle agent keeps its followers; a departed one releases them', () => {
const { store, pageId } = setup()
const agent = addAgent(store, 'chat')
agent.update({ status: 'editing', cursor: { x: 10, y: 10, pageId } })
follow(store, { kind: 'agent', agentId: agent.id })
agent.update({ status: 'idle', cursor: undefined })
expect(presenceOf(store).following.value).toEqual({ kind: 'agent', agentId: agent.id })
agent.remove()
expect(presenceOf(store).following.value).toBeNull()
})
test('following a person matches their zoom, and stops when they leave', () => {
const { store, pageId } = setup()
const ana = { clientId: 4, name: 'Ana', color: red, agents: [] }
setPeers(store, [{ ...ana, cursor: { x: 40, y: 50, pageId, zoom: 2 } }])
follow(store, { kind: 'person', clientId: 4 })
expect(store.state.zoom).toBe(2)
expect(centered(store)).toEqual({ x: 40, y: 50 })
setPeers(store, [])
expect(presenceOf(store).following.value).toBeNull()
})
test('switching to another page yourself stops following', async () => {
const { store, pageId, other } = setup()
store.preparationController.acknowledgePresentation(Number.MAX_SAFE_INTEGER)
const ana = { clientId: 4, name: 'Ana', color: red, agents: [] }
setPeers(store, [{ ...ana, cursor: { x: 40, y: 50, pageId, zoom: 1 } }])
follow(store, { kind: 'person', clientId: 4 })
expect(followedLabel(store)).toEqual({ kind: 'person', name: 'Ana', color: red })
await store.switchPage(other)
expect(presenceOf(store).following.value).toBeNull()
})
test('following survives the target moving on while its page switch is in flight', async () => {
const { store, other } = setup()
store.preparationController.acknowledgePresentation(Number.MAX_SAFE_INTEGER)
const third = store.graph.addPage('Third').id
const agent = addAgent(store, 'chat')
agent.update({ status: 'editing', cursor: { x: 10, y: 10, pageId: other } })
const reachedThird = new Promise<void>((resolve) => {
store.onEditorEvent('page:changed', (id) => {
if (id === third) resolve()
})
})
follow(store, { kind: 'agent', agentId: agent.id })
// Before the switch to `other` lands, the agent moves to a third page.
agent.update({ cursor: { x: 20, y: 20, pageId: third } })
await reachedThird
expect(presenceOf(store).following.value).toEqual({ kind: 'agent', agentId: agent.id })
expect(store.state.currentPageId).toBe(third)
})
test('switching pages yourself stops following a resting agent too', async () => {
const { store, other } = setup()
store.preparationController.acknowledgePresentation(Number.MAX_SAFE_INTEGER)
const agent = addAgent(store, 'chat')
follow(store, { kind: 'agent', agentId: agent.id })
await store.switchPage(other)
expect(presenceOf(store).following.value).toBeNull()
})
test('waits for a person who has not pointed anywhere yet', () => {
const { store, pageId } = setup()
const ana = { clientId: 4, name: 'Ana', color: red, agents: [] }
setPeers(store, [ana])
follow(store, { kind: 'person', clientId: 4 })
expect(presenceOf(store).following.value).toEqual({ kind: 'person', clientId: 4 })
setPeers(store, [{ ...ana, cursor: { x: 40, y: 50, pageId, zoom: 1 } }])
expect(centered(store)).toEqual({ x: 40, y: 50 })
})
test('zooming yourself stops following', () => {
const { store, pageId } = setup()
const ana = { clientId: 4, name: 'Ana', color: red, agents: [] }
setPeers(store, [{ ...ana, cursor: { x: 40, y: 50, pageId, zoom: 1 } }])
follow(store, { kind: 'person', clientId: 4 })
setPeers(store, [{ ...ana, cursor: { x: 60, y: 70, pageId, zoom: 1 } }])
expect(presenceOf(store).following.value).toEqual({ kind: 'person', clientId: 4 })
store.applyZoom(-100, 960, 540)
expect(presenceOf(store).following.value).toBeNull()
})
test('cursor updates while heading to a page do not restart the switch', async () => {
const { store, other } = setup()
store.preparationController.acknowledgePresentation(Number.MAX_SAFE_INTEGER)
const switchPage = spyOn(store, 'switchPage')
const agent = addAgent(store, 'chat')
agent.update({ status: 'editing', cursor: { x: 10, y: 10, pageId: other } })
const arrived = new Promise<void>((resolve) => {
store.onEditorEvent('page:changed', () => resolve())
})
follow(store, { kind: 'agent', agentId: agent.id })
for (const x of [20, 30, 40]) agent.update({ cursor: { x, y: 10, pageId: other } })
await arrived
expect(switchPage).toHaveBeenCalledTimes(1)
expect(store.state.currentPageId).toBe(other)
})
test('zooming while following heads to another page stops following', () => {
const { store, other } = setup()
const agent = addAgent(store, 'chat')
agent.update({ status: 'editing', cursor: { x: 10, y: 10, pageId: other } })
follow(store, { kind: 'agent', agentId: agent.id })
store.applyZoom(-100, 960, 540)
expect(presenceOf(store).following.value).toBeNull()
})
test('a cursor on a page this document lacks is waited out, not switched to', () => {
const { store } = setup()
const switchPage = spyOn(store, 'switchPage')
const ana = { clientId: 4, name: 'Ana', color: red, agents: [] }
setPeers(store, [{ ...ana, cursor: { x: 1, y: 1, pageId: 'missing', zoom: 1 } }])
follow(store, { kind: 'person', clientId: 4 })
expect(switchPage).not.toHaveBeenCalled()
expect(presenceOf(store).following.value).toEqual({ kind: 'person', clientId: 4 })
})
test('stopping while following heads to another page stays where you are', async () => {
const { store, pageId, other } = setup()
store.preparationController.acknowledgePresentation(Number.MAX_SAFE_INTEGER)
const agent = addAgent(store, 'chat')
agent.update({ status: 'editing', cursor: { x: 10, y: 10, pageId: other } })
follow(store, { kind: 'agent', agentId: agent.id })
follow(store, null)
await new Promise((resolve) => {
setTimeout(resolve, 20)
})
expect(store.state.currentPageId).toBe(pageId)
})
test('labels a followed agent with the person who runs it', () => {
const { store, pageId } = setup()
const fern = { id: 'fern', name: 'Fern', kind: 'chat' as const, status: 'editing' as const }
setPeers(store, [
{ clientId: 4, name: 'Ana', color: red, agents: [{ ...fern, cursor: { x: 1, y: 1, pageId } }] }
])
follow(store, { kind: 'agent', agentId: 'fern' })
expect(followedLabel(store)).toEqual({ kind: 'agent', name: 'Fern', owner: 'Ana', color: red })
})
test('renames our agents, and their handles report the new name', () => {
const { store } = setup()
const agent = addAgent(store, 'chat')
renameAgent(store, agent.id, ' Juniper ')
expect(agent.name).toBe('Juniper')
renameAgent(store, agent.id, ' ')
expect(agent.name).toBe('Juniper')
})

View file

@ -0,0 +1,69 @@
import { expect, test } from '@playwright/test'
import { startAgent } from '#tests/helpers/collab/agent'
import {
collaborationErrors,
connect,
createPeer,
startRelay,
type Peer
} from '#tests/helpers/collab/room'
function pageNames(peer: Peer) {
return peer.page.evaluate(
() =>
window.openPencil
?.getStore?.()
.graph.getPages()
.map((page) => page.name) ?? []
)
}
test("a guest's agent shows on their avatar and can be followed until Escape", async ({
browser
}) => {
const relay = await startRelay()
let host: Peer | null = null
let guest: Peer | null = null
try {
host = await createPeer(browser, 'Host', relay.url)
guest = await createPeer(browser, 'Guest', relay.url)
await connect(host)
await connect(guest)
await expect
.poll(() => host?.page.evaluate(() => window.openPencil?.test?.collab?.peerCount()))
.toBe(1)
await host.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.graph.addPage('Checkout')
})
const peer = guest
await expect.poll(() => pageNames(peer)).toContain('Checkout')
const agent = await startAgent(guest.page, 'Checkout', 300, 200)
// The guest's avatar carries a count of their agents; hovering lists them.
const avatar = host.page.getByTestId('collab-peer-avatar')
await expect(avatar).toContainText('1')
await avatar.hover()
const card = host.page.getByTestId('collab-peer-card')
await expect(card.getByText(agent.name)).toBeVisible()
await expect(card.getByText('Editing · Checkout')).toBeVisible()
await card.getByRole('button', { name: `Follow ${agent.name}` }).click()
await expect
.poll(() => host?.page.evaluate(() => window.openPencil?.getStore?.().state.currentPageId))
.toBe(agent.pageId)
const frame = host.page.getByTestId('follow-frame')
await expect(frame).toContainText(`Following ${agent.name}`)
await host.page.keyboard.press('Escape')
await expect(frame).toHaveCount(0)
expect(collaborationErrors(host)).toEqual([])
} finally {
await host?.context.close()
await guest?.context.close()
await relay.close()
}
})

View file

@ -1,123 +1,12 @@
import { expect, test, type Browser, type BrowserContext, type Page } from '@playwright/test'
import { WebSocketServer, type WebSocket } from 'ws'
import { expect, test } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
const ROOM_ID = 'e2e-collaboration-room'
type TestRelay = {
url: string
pause: () => void
resume: () => void
queuedCount: () => number
close: () => Promise<void>
}
async function startRelay(): Promise<TestRelay> {
const rooms = new Map<string, Set<WebSocket>>()
const sockets = new Map<WebSocket, { room: Set<WebSocket>; peerId: string | null }>()
const queuedMessages: Array<{ sender: WebSocket; room: Set<WebSocket>; text: string }> = []
let paused = false
const server = new WebSocketServer({ host: '127.0.0.1', port: 0 })
server.on('connection', (socket, request) => {
const roomId = new URL(request.url ?? '/', 'ws://127.0.0.1').searchParams.get('roomId') ?? ''
let room = rooms.get(roomId)
if (!room) {
room = new Set()
rooms.set(roomId, room)
}
room.add(socket)
sockets.set(socket, { room, peerId: null })
socket.on('message', (data) => {
const text = data.toString()
const message = JSON.parse(text) as { senderId?: string }
const state = sockets.get(socket)
if (state && message.senderId) state.peerId = message.senderId
if (paused) {
queuedMessages.push({ sender: socket, room, text })
return
}
for (const peer of room) {
if (peer !== socket && peer.readyState === peer.OPEN) peer.send(text)
}
})
socket.on('close', () => {
const state = sockets.get(socket)
room?.delete(socket)
sockets.delete(socket)
if (!state?.peerId) return
const leave = JSON.stringify({ type: 'leave', senderId: state.peerId })
for (const peer of state.room) if (peer.readyState === peer.OPEN) peer.send(leave)
})
})
await new Promise<void>((resolve, reject) => {
server.once('listening', () => resolve())
server.once('error', reject)
})
const address = server.address()
if (typeof address === 'string' || address === null) throw new Error('Test relay unavailable')
return {
url: `ws://127.0.0.1:${address.port}`,
pause: () => {
paused = true
},
resume: () => {
paused = false
for (const message of queuedMessages.splice(0)) {
for (const peer of message.room) {
if (peer !== message.sender && peer.readyState === peer.OPEN) peer.send(message.text)
}
}
},
queuedCount: () => queuedMessages.length,
close: async () => {
for (const room of rooms.values()) for (const socket of room) socket.terminate()
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error)
else resolve()
})
})
}
}
}
type Peer = {
context: BrowserContext
page: Page
canvas: CanvasHelper
}
async function createPeer(browser: Browser, name: string, relayURL: string): Promise<Peer> {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } })
try {
const page = await context.newPage()
await page.goto(`/?test&collabTransport=test&collabRelay=${encodeURIComponent(relayURL)}`)
await page.evaluate(
(localName) => window.openPencil?.test?.collab?.setLocalName(localName),
name
)
const canvas = new CanvasHelper(page)
await canvas.waitForInit()
canvas.errors.length = 0
return { context, page, canvas }
} catch (error) {
await context.close()
throw error
}
}
function collaborationErrors(peer: Peer): string[] {
return peer.canvas.errors.filter((error) => !error.includes('127.0.0.1:7600'))
}
async function connect(peer: Peer) {
await peer.page.evaluate((roomId) => {
const collab = window.openPencil?.test?.collab
if (!collab) throw new Error('Collaboration bridge unavailable')
collab.connect(roomId)
}, ROOM_ID)
}
import {
collaborationErrors,
connect,
createPeer,
startRelay,
type Peer
} from '#tests/helpers/collab/room'
test('mobile presence popover returns focus and disconnects the peer', async ({ browser }) => {
const relay = await startRelay()

View file

@ -0,0 +1,45 @@
import { expect, test } from '@playwright/test'
const story = (id: string) => `/iframe.html?id=${id}&viewMode=story`
// Stories with play functions click on their own; these use ones that do not.
test("an avatar counts its person's agents and lists them on hover", async ({ page }) => {
await page.goto(story('collaboration-presence-avatars--many-people'))
const ana = page.getByRole('button', { name: 'Follow Ana' })
await expect(ana).toContainText('2')
await ana.hover()
const card = page.getByText('Thinking · Cover')
await expect(card).toBeVisible()
await expect(page.getByRole('button', { name: 'Follow Orbit' })).toBeVisible()
})
test('more people than fit collapse into +N, which lists everyone', async ({ page }) => {
await page.goto(story('collaboration-presence-avatars--many-people'))
await page.getByRole('button', { name: '2 more' }).click()
await expect(page.getByText('Fay')).toBeVisible()
await expect(page.getByText('Dana (you)')).toBeVisible()
})
test('your avatar shows the room is live and opens your menu with Leave room', async ({ page }) => {
await page.goto(story('collaboration-presence-avatars--many-people'))
await expect(page.getByRole('img', { name: 'Connected' })).toBeVisible()
await page.getByRole('button', { name: 'Dana (you)' }).click()
await expect(page.getByRole('button', { name: 'Leave room' })).toBeVisible()
})
test('the follow frame names an agent with its owner and offers to stop', async ({ page }) => {
await page.goto(story('collaboration-follow-frame--following-their-agent'))
await expect(page.getByRole('status')).toHaveText(/Following Orbit \(Ana\)/)
await expect(page.getByRole('button', { name: 'Stop following' })).toBeVisible()
})
test("the keyboard reaches a collaborator's agents through the room list", async ({ page }) => {
await page.goto(story('collaboration-presence-avatars--room'))
const everyone = page.getByRole('button', { name: 'In this room' })
await everyone.focus()
await everyone.press('Enter')
const follow = page.getByRole('button', { name: 'Follow Orbit' })
await expect(follow).toBeVisible()
await follow.focus()
await expect(follow).toBeFocused()
})

View file

@ -0,0 +1,31 @@
import { expect, test } from '@playwright/test'
const story = (name: string) =>
`/iframe.html?id=collaboration-presence-list--${name}&viewMode=story`
test('lists people with their agents and what each agent is doing', async ({ page }) => {
await page.goto(story('room'))
const list = page.getByRole('list').first()
await expect(list.getByText('Dana (you)')).toBeVisible()
await expect(list.getByText('Editing · Checkout')).toBeVisible()
await expect(list.getByText('Thinking · Cover')).toBeVisible()
await expect(list.getByText('Idle', { exact: true })).toBeVisible()
await expect(list.getByRole('button', { name: 'Follow Ana' })).toBeVisible()
await expect(list.getByRole('button', { name: 'Follow Dana' })).toHaveCount(0)
await expect(list.getByRole('button', { name: 'Rename Orbit' })).toHaveCount(0)
})
test('renames your own agent inline', async ({ page }) => {
await page.goto(story('room'))
await page.getByRole('button', { name: 'Rename Fern' }).click()
const input = page.getByRole('textbox', { name: 'Agent name' })
await expect(input).toBeFocused()
await input.press('Escape')
await expect(input).toHaveCount(0)
})
test('offers to stop following the followed agent', async ({ page }) => {
await page.goto(story('following-an-agent'))
await expect(page.getByRole('button', { name: 'Stop following Orbit' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Follow Fern' })).toBeVisible()
})

View file

@ -0,0 +1,12 @@
import { getActiveEditorStore } from '@/app/editor/active-store'
import { addAgent } from '@/app/presence/registry'
/** Test-only browser entrypoint: start an agent editing at a point on a named page. */
export function startTestAgent(pageName: string, x: number, y: number) {
const store = getActiveEditorStore()
const page = store.graph.getPages().find((candidate) => candidate.name === pageName)
if (!page) throw new Error(`Page not found: ${pageName}`)
const agent = addAgent(store, 'chat')
agent.update({ status: 'editing', cursor: { x, y, pageId: page.id } })
return { name: agent.name, pageId: page.id }
}

View file

@ -0,0 +1,15 @@
import type { Page } from '@playwright/test'
import type * as AgentFixture from './agent-browser'
/** Start an agent in the peer's browser; returns its callsign and page. */
export function startAgent(page: Page, pageName: string, x: number, y: number) {
return page.evaluate(
async ({ pageName, x, y }) => {
const fixturePath = '/tests/helpers/collab/agent-browser.ts'
const fixture: typeof AgentFixture = await import(fixturePath)
return fixture.startTestAgent(pageName, x, y)
},
{ pageName, x, y }
)
}

View file

@ -0,0 +1,133 @@
import type { Browser, BrowserContext, Page } from '@playwright/test'
import { WebSocketServer, type WebSocket } from 'ws'
import { CanvasHelper } from '#tests/helpers/canvas'
/** Two-browser collaboration: a local WebSocket relay and peers connected through it. */
export const ROOM_ID = 'e2e-collaboration-room'
export type TestRelay = {
url: string
pause: () => void
resume: () => void
queuedCount: () => number
close: () => Promise<void>
}
/** The sender of a relayed frame; frames that are not JSON are still forwarded. */
function senderOf(text: string): string | undefined {
try {
const message: unknown = JSON.parse(text)
return typeof message === 'object' && message !== null && 'senderId' in message
? String(message.senderId)
: undefined
} catch {
return undefined
}
}
export async function startRelay(): Promise<TestRelay> {
const rooms = new Map<string, Set<WebSocket>>()
const sockets = new Map<WebSocket, { room: Set<WebSocket>; peerId: string | null }>()
const queuedMessages: Array<{ sender: WebSocket; room: Set<WebSocket>; text: string }> = []
let paused = false
const server = new WebSocketServer({ host: '127.0.0.1', port: 0 })
server.on('connection', (socket, request) => {
const roomId = new URL(request.url ?? '/', 'ws://127.0.0.1').searchParams.get('roomId') ?? ''
let room = rooms.get(roomId)
if (!room) {
room = new Set()
rooms.set(roomId, room)
}
room.add(socket)
sockets.set(socket, { room, peerId: null })
socket.on('message', (data) => {
const text = data.toString()
const state = sockets.get(socket)
const senderId = senderOf(text)
if (state && senderId) state.peerId = senderId
if (paused) {
queuedMessages.push({ sender: socket, room, text })
return
}
for (const peer of room) {
if (peer !== socket && peer.readyState === peer.OPEN) peer.send(text)
}
})
socket.on('close', () => {
const state = sockets.get(socket)
room?.delete(socket)
sockets.delete(socket)
if (!state?.peerId) return
const leave = JSON.stringify({ type: 'leave', senderId: state.peerId })
for (const peer of state.room) if (peer.readyState === peer.OPEN) peer.send(leave)
})
})
await new Promise<void>((resolve, reject) => {
server.once('listening', () => resolve())
server.once('error', reject)
})
const address = server.address()
if (typeof address === 'string' || address === null) throw new Error('Test relay unavailable')
return {
url: `ws://127.0.0.1:${address.port}`,
pause: () => {
paused = true
},
resume: () => {
paused = false
for (const message of queuedMessages.splice(0)) {
for (const peer of message.room) {
if (peer !== message.sender && peer.readyState === peer.OPEN) peer.send(message.text)
}
}
},
queuedCount: () => queuedMessages.length,
close: async () => {
for (const room of rooms.values()) for (const socket of room) socket.terminate()
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error)
else resolve()
})
})
}
}
}
export type Peer = {
context: BrowserContext
page: Page
canvas: CanvasHelper
}
export async function createPeer(browser: Browser, name: string, relayURL: string): Promise<Peer> {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } })
try {
const page = await context.newPage()
await page.goto(`/?test&collabTransport=test&collabRelay=${encodeURIComponent(relayURL)}`)
await page.evaluate(
(localName) => window.openPencil?.test?.collab?.setLocalName(localName),
name
)
const canvas = new CanvasHelper(page)
await canvas.waitForInit()
canvas.errors.length = 0
return { context, page, canvas }
} catch (error) {
await context.close()
throw error
}
}
export function collaborationErrors(peer: Peer): string[] {
return peer.canvas.errors.filter((error) => !error.includes('127.0.0.1:7600'))
}
export async function connect(peer: Peer) {
await peer.page.evaluate((roomId) => {
const collab = window.openPencil?.test?.collab
if (!collab) throw new Error('Collaboration bridge unavailable')
collab.connect(roomId)
}, ROOM_ID)
}

View file

@ -45,7 +45,7 @@ describe('dynamic Tailwind state classes', () => {
test('rejects dynamic utility state in previously audited files', () => {
const audited = `<template>${'\n'.repeat(36)}<button :class="active ? 'bg-hover' : 'text-muted'" /></template>`
expect(
dynamicClassDiagnostics('src/components/CollabPanel/CollabAvatarStack.vue', audited)
dynamicClassDiagnostics('src/components/collab-panel/CollabAvatarStack.vue', audited)
).toHaveLength(1)
expect(
dynamicClassDiagnostics(