{{ hud.messages.inThisRoom }}
@@ -40,7 +39,7 @@ function peerAvatarClass(following: boolean) {
{{ initials(hud.collabState.localName || 'You') }}
@@ -59,7 +58,7 @@ function peerAvatarClass(following: boolean) {
@click="hud.toggleFollowPeer(peer.clientId)"
>
{{ initials(peer.name) }}
diff --git a/src/components/collab-panel/CollabAvatarStack.vue b/src/components/collab-panel/CollabAvatarStack.vue
new file mode 100644
index 000000000..da3c03954
--- /dev/null
+++ b/src/components/collab-panel/CollabAvatarStack.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/src/components/collab-panel/CollabPanel.vue b/src/components/collab-panel/CollabPanel.vue
new file mode 100644
index 000000000..b1c9a5730
--- /dev/null
+++ b/src/components/collab-panel/CollabPanel.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/src/components/CollabPanel/CollabSharePopover.vue b/src/components/collab-panel/CollabSharePopover.vue
similarity index 60%
rename from src/components/CollabPanel/CollabSharePopover.vue
rename to src/components/collab-panel/CollabSharePopover.vue
index 819c4033c..4ae581aa3 100644
--- a/src/components/CollabPanel/CollabSharePopover.vue
+++ b/src/components/collab-panel/CollabSharePopover.vue
@@ -1,14 +1,13 @@
@@ -27,16 +24,10 @@ const styles = computed(() => collaboration({ connection: connection.value }))
diff --git a/src/components/CollabPanel/ConnectedRoom.vue b/src/components/collab-panel/ConnectedRoom.vue
similarity index 67%
rename from src/components/CollabPanel/ConnectedRoom.vue
rename to src/components/collab-panel/ConnectedRoom.vue
index 607a5c463..e8a2f7ac7 100644
--- a/src/components/CollabPanel/ConnectedRoom.vue
+++ b/src/components/collab-panel/ConnectedRoom.vue
@@ -1,7 +1,7 @@
+
+
+
+
+
+
+
+
+
+ emit('rename', id, name)"
+ />
+
+ {{ messages.leaveRoom }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ emit('rename', id, name)"
+ />
+
+
+
+
+
diff --git a/src/components/collab-panel/PresenceList.stories.ts b/src/components/collab-panel/PresenceList.stories.ts
new file mode 100644
index 000000000..4b402aeb7
--- /dev/null
+++ b/src/components/collab-panel/PresenceList.stories.ts
@@ -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: ''
+ })
+} satisfies Meta void; onRename: () => void }>
+
+export default meta
+type Story = StoryObj
+
+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) }
+}
diff --git a/src/components/collab-panel/PresenceList.vue b/src/components/collab-panel/PresenceList.vue
new file mode 100644
index 000000000..6b15d0382
--- /dev/null
+++ b/src/components/collab-panel/PresenceList.vue
@@ -0,0 +1,115 @@
+
+
+
+
+ -
+
+
+ {{ initials(person.name || common.you) }}
+
+
+ {{
+ person.clientId === undefined
+ ? `${person.name || common.you} (${common.youSuffix})`
+ : person.name
+ }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/CollabPanel/ShareOrJoinRoom.vue b/src/components/collab-panel/ShareOrJoinRoom.vue
similarity index 95%
rename from src/components/CollabPanel/ShareOrJoinRoom.vue
rename to src/components/collab-panel/ShareOrJoinRoom.vue
index 32fc4d651..6d6c191ab 100644
--- a/src/components/CollabPanel/ShareOrJoinRoom.vue
+++ b/src/components/collab-panel/ShareOrJoinRoom.vue
@@ -1,5 +1,5 @@
+
+
+
+
+
+ {{ text }}
+
+
+
+
diff --git a/src/theme/collaboration.ts b/src/theme/collaboration.ts
deleted file mode 100644
index 65b9589e2..000000000
--- a/src/theme/collaboration.ts
+++ /dev/null
@@ -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
diff --git a/src/theme/collaboration/avatar.ts b/src/theme/collaboration/avatar.ts
new file mode 100644
index 000000000..be47249c9
--- /dev/null
+++ b/src/theme/collaboration/avatar.ts
@@ -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 }
+})
diff --git a/src/theme/collaboration/follow-frame.ts b/src/theme/collaboration/follow-frame.ts
new file mode 100644
index 000000000..648424849
--- /dev/null
+++ b/src/theme/collaboration/follow-frame.ts
@@ -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'
+ }
+})
diff --git a/src/theme/collaboration/presence-avatars.ts b/src/theme/collaboration/presence-avatars.ts
new file mode 100644
index 000000000..60df7dc44
--- /dev/null
+++ b/src/theme/collaboration/presence-avatars.ts
@@ -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'
+ }
+})
diff --git a/src/theme/collaboration/presence-list.ts b/src/theme/collaboration/presence-list.ts
new file mode 100644
index 000000000..7c4947051
--- /dev/null
+++ b/src/theme/collaboration/presence-list.ts
@@ -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'
+ }
+})
diff --git a/src/theme/collaboration/share-button.ts b/src/theme/collaboration/share-button.ts
new file mode 100644
index 000000000..8fda78095
--- /dev/null
+++ b/src/theme/collaboration/share-button.ts
@@ -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' }
+})
diff --git a/src/theme/mobile/presence-popover.ts b/src/theme/mobile/presence-popover.ts
new file mode 100644
index 000000000..4ce27df5f
--- /dev/null
+++ b/src/theme/mobile/presence-popover.ts
@@ -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'
+ }
+})
diff --git a/tests/app/presence/registry.test.ts b/tests/app/presence/registry.test.ts
index 4a28f1b0e..afe0525b4 100644
--- a/tests/app/presence/registry.test.ts
+++ b/tests/app/presence/registry.test.ts
@@ -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[] = []
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) {
+ 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((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((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((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')
+})
diff --git a/tests/e2e/collab/agents.spec.ts b/tests/e2e/collab/agents.spec.ts
new file mode 100644
index 000000000..533eeecb9
--- /dev/null
+++ b/tests/e2e/collab/agents.spec.ts
@@ -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()
+ }
+})
diff --git a/tests/e2e/collab/session.spec.ts b/tests/e2e/collab/session.spec.ts
index cf564bb05..76f9f57b5 100644
--- a/tests/e2e/collab/session.spec.ts
+++ b/tests/e2e/collab/session.spec.ts
@@ -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
-}
-
-async function startRelay(): Promise {
- const rooms = new Map>()
- const sockets = new Map; peerId: string | null }>()
- const queuedMessages: Array<{ sender: WebSocket; room: Set; 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((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((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 {
- 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()
diff --git a/tests/e2e/storybook/presence/avatars.spec.ts b/tests/e2e/storybook/presence/avatars.spec.ts
new file mode 100644
index 000000000..00b2bd433
--- /dev/null
+++ b/tests/e2e/storybook/presence/avatars.spec.ts
@@ -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()
+})
diff --git a/tests/e2e/storybook/presence-cursors.spec.ts b/tests/e2e/storybook/presence/cursors.spec.ts
similarity index 100%
rename from tests/e2e/storybook/presence-cursors.spec.ts
rename to tests/e2e/storybook/presence/cursors.spec.ts
diff --git a/tests/e2e/storybook/presence/list.spec.ts b/tests/e2e/storybook/presence/list.spec.ts
new file mode 100644
index 000000000..fd52fd678
--- /dev/null
+++ b/tests/e2e/storybook/presence/list.spec.ts
@@ -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()
+})
diff --git a/tests/helpers/collab/agent-browser.ts b/tests/helpers/collab/agent-browser.ts
new file mode 100644
index 000000000..d47b62bb1
--- /dev/null
+++ b/tests/helpers/collab/agent-browser.ts
@@ -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 }
+}
diff --git a/tests/helpers/collab/agent.ts b/tests/helpers/collab/agent.ts
new file mode 100644
index 000000000..c0a848cb7
--- /dev/null
+++ b/tests/helpers/collab/agent.ts
@@ -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 }
+ )
+}
diff --git a/tests/helpers/collab/room.ts b/tests/helpers/collab/room.ts
new file mode 100644
index 000000000..b896a44d1
--- /dev/null
+++ b/tests/helpers/collab/room.ts
@@ -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
+}
+
+/** 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 {
+ const rooms = new Map>()
+ const sockets = new Map; peerId: string | null }>()
+ const queuedMessages: Array<{ sender: WebSocket; room: Set; 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((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((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 {
+ 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)
+}
diff --git a/tools/checks/architecture/tests/dynamic-classes.test.ts b/tools/checks/architecture/tests/dynamic-classes.test.ts
index dbf3958bb..8b49f0b5b 100644
--- a/tools/checks/architecture/tests/dynamic-classes.test.ts
+++ b/tools/checks/architecture/tests/dynamic-classes.test.ts
@@ -45,7 +45,7 @@ describe('dynamic Tailwind state classes', () => {
test('rejects dynamic utility state in previously audited files', () => {
const audited = `${'\n'.repeat(36)}`
expect(
- dynamicClassDiagnostics('src/components/CollabPanel/CollabAvatarStack.vue', audited)
+ dynamicClassDiagnostics('src/components/collab-panel/CollabAvatarStack.vue', audited)
).toHaveLength(1)
expect(
dynamicClassDiagnostics(