void }
+
+const meta = {
+ title: 'Chat/Run Location',
+ component: ChatRunLocation,
+ tags: ['autodocs'],
+ args: { agent: 'Fern', page: 'Checkout', onOpen: fn() },
+ render: (args) => ({
+ components: { ChatRunLocation },
+ setup: () => ({ args }),
+ template: '
'
+ })
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const OnAnotherPage: Story = {
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement)
+ await expect(canvas.getByRole('status')).toHaveTextContent('Fern is working on “Checkout”')
+ await userEvent.click(canvas.getByRole('button', { name: 'Go to page' }))
+ await expect(args.onOpen).toHaveBeenCalledOnce()
+ }
+}
+
+export const LongPageName: Story = {
+ args: { page: 'Onboarding flow — empty states, errors, and the long tail of edge cases' }
+}
diff --git a/src/components/chat/ChatRunLocation.vue b/src/components/chat/ChatRunLocation.vue
new file mode 100644
index 000000000..9f086490b
--- /dev/null
+++ b/src/components/chat/ChatRunLocation.vue
@@ -0,0 +1,21 @@
+
+
+
+
+ {{ ai.chatAgentWorkingOn({ agent, page }) }}
+ {{ ai.chatGoToPage }}
+
+
diff --git a/src/components/presence/PresenceMarkers.stories.ts b/src/components/presence/PresenceMarkers.stories.ts
new file mode 100644
index 000000000..bf850110b
--- /dev/null
+++ b/src/components/presence/PresenceMarkers.stories.ts
@@ -0,0 +1,70 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+import { expect, within } from 'storybook/test'
+
+import type { PagePresenceEntry } from '@/app/presence/registry'
+
+import PresenceMarkers from './PresenceMarkers.vue'
+
+const ana = { r: 0.92, g: 0.34, b: 0.29, a: 1 }
+const ben = { r: 0.2, g: 0.55, b: 0.95, a: 1 }
+const you = { r: 0.3, g: 0.7, b: 0.45, a: 1 }
+
+const anaWithAgent: PagePresenceEntry[] = [
+ { id: 'person:ana', kind: 'person', name: 'Ana', color: ana },
+ { id: 'agent:orbit', kind: 'agent', name: 'Orbit', color: ana }
+]
+
+type Args = { entries: PagePresenceEntry[] }
+
+const meta = {
+ title: 'Collaboration/Page Presence Markers',
+ component: PresenceMarkers,
+ tags: ['autodocs'],
+ args: { entries: anaWithAgent },
+ render: (args) => ({
+ components: { PresenceMarkers },
+ setup: () => ({ args }),
+ template:
+ ''
+ })
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Person: Story = {
+ args: { entries: [{ id: 'person:ben', kind: 'person', name: 'Ben', color: ben }] }
+}
+
+export const PersonAndTheirAgent: Story = {
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).getByRole('img')).toHaveAccessibleName('Ana, Orbit')
+ }
+}
+
+export const YourAgent: Story = {
+ args: { entries: [{ id: 'agent:fern', kind: 'agent', name: 'Fern', color: you }] }
+}
+
+export const MoreThanFit: Story = {
+ args: {
+ entries: [
+ ...anaWithAgent,
+ { id: 'person:ben', kind: 'person', name: 'Ben', color: ben },
+ { id: 'agent:pixel', kind: 'agent', name: 'Pixel', color: ben },
+ { id: 'agent:fern', kind: 'agent', name: 'Fern', color: you }
+ ]
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ await expect(canvas.getByText('+2')).toBeVisible()
+ await expect(canvas.getByRole('img')).toHaveAccessibleName('Ana, Orbit, Ben, Pixel, Fern')
+ }
+}
+
+export const Nobody: Story = {
+ args: { entries: [] },
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).queryByRole('img')).toBeNull()
+ }
+}
diff --git a/src/components/presence/PresenceMarkers.vue b/src/components/presence/PresenceMarkers.vue
new file mode 100644
index 000000000..b78faf060
--- /dev/null
+++ b/src/components/presence/PresenceMarkers.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+ +{{ hidden }}
+
+
+
diff --git a/src/theme/collaboration/page-markers.ts b/src/theme/collaboration/page-markers.ts
new file mode 100644
index 000000000..7ae3eb585
--- /dev/null
+++ b/src/theme/collaboration/page-markers.ts
@@ -0,0 +1,11 @@
+import { tv } from 'tailwind-variants'
+
+/** Who works on a page: a dot per person, a sparkle per agent, then "+N". */
+export const pageMarkers = tv({
+ slots: {
+ root: 'flex shrink-0 items-center gap-1',
+ person: 'size-2 rounded-full',
+ agent: 'size-3',
+ overflow: 'text-[9px] leading-none text-muted'
+ }
+})
diff --git a/src/theme/page-list.ts b/src/theme/page-list.ts
index b1471766e..467273e5a 100644
--- a/src/theme/page-list.ts
+++ b/src/theme/page-list.ts
@@ -15,7 +15,9 @@ const pageListTheme = {
divider: 'my-1 flex cursor-pointer items-center px-2',
dividerLine: 'h-px flex-1 bg-border',
item: 'flex h-6 w-full cursor-pointer items-center gap-1.5 rounded border-none px-2 text-left text-[11px] outline-none focus-visible:ring-1 focus-visible:ring-panel-focus',
- label: 'truncate'
+ label: 'min-w-0 flex-1 truncate',
+ /** Right-aligned page badges, such as who works on the page. */
+ trailing: 'flex shrink-0 items-center gap-1.5'
},
variants: {
active: {
diff --git a/tests/app/ai/tools/run.test.ts b/tests/app/ai/tools/run.test.ts
index 1b1ca6587..59999d6f7 100644
--- a/tests/app/ai/tools/run.test.ts
+++ b/tests/app/ai/tools/run.test.ts
@@ -132,6 +132,7 @@ test("the agent's switch_page moves the run and the user's view", async () => {
expect(runPageId(store)).toBe(b)
expect(store.graph.getChildren(b)).toHaveLength(1)
+ expect(presenceOf(store).agents.value[0]?.pageId).toBe(b)
expect(store.state.currentPageId).toBe(b)
})
})
diff --git a/tests/app/presence/registry.test.ts b/tests/app/presence/registry.test.ts
index afe0525b4..12240028f 100644
--- a/tests/app/presence/registry.test.ts
+++ b/tests/app/presence/registry.test.ts
@@ -6,6 +6,7 @@ import {
addAgent,
follow,
followedLabel,
+ presenceByPage,
presenceOf,
renameAgent,
setOwnerColor,
@@ -245,3 +246,40 @@ test('renames our agents, and their handles report the new name', () => {
renameAgent(store, agent.id, ' ')
expect(agent.name).toBe('Juniper')
})
+
+test("lists people and working agents by page, knowing an agent's page before its first edit", () => {
+ const { store, pageId, other } = setup()
+ setPeers(store, [
+ {
+ clientId: 5,
+ name: 'Ana',
+ color: red,
+ cursor: { x: 0, y: 0, pageId },
+ agents: [{ id: 'o', name: 'Orbit', kind: 'mcp', status: 'idle', pageId: other }]
+ }
+ ])
+ const thinking = addAgent(store, 'chat')
+ thinking.update({ status: 'thinking', pageId: other })
+ expect(
+ presenceByPage(store)
+ .get(pageId)
+ ?.map((entry) => entry.name)
+ ).toEqual(['Ana'])
+ expect(presenceByPage(store).get(other)).toEqual([
+ { id: `agent:${thinking.id}`, kind: 'agent', name: thinking.name, color: expect.anything() }
+ ])
+})
+
+test('keeps people with the same name apart on a page', () => {
+ const { store, pageId } = setup()
+ const anonymous = { name: 'Anonymous', color: red, agents: [], cursor: { x: 0, y: 0, pageId } }
+ setPeers(store, [
+ { ...anonymous, clientId: 4 },
+ { ...anonymous, clientId: 5 }
+ ])
+ expect(
+ presenceByPage(store)
+ .get(pageId)
+ ?.map((entry) => entry.id)
+ ).toEqual(['person:4', 'person:5'])
+})
diff --git a/tests/e2e/chat/streaming/jsx.spec.ts b/tests/e2e/chat/streaming/jsx.spec.ts
index 9f1b8dd99..b7dfeaacb 100644
--- a/tests/e2e/chat/streaming/jsx.spec.ts
+++ b/tests/e2e/chat/streaming/jsx.spec.ts
@@ -123,8 +123,19 @@ test('keeps the run and its preview on their page while the user views another',
expect(await previewKey(chat.page)).toBe('')
await expect(chat.page.getByTestId('chat-stop-button')).toBeVisible()
- await switchPage(chat.page, pages.current)
+ // Away from the run's page: the chat says where it works, and the page list marks it.
+ const location = chat.page.getByTestId('chat-run-location')
+ await expect(location).toContainText('is working on “Page 1”')
+ await expect(
+ chat.page
+ .getByTestId('pages-row')
+ .filter({ hasText: 'Page 1' })
+ .getByTestId('presence-markers')
+ ).toBeVisible()
+
+ await location.getByRole('button', { name: 'Go to page' }).click()
await expect.poll(() => previewKey(chat.page)).not.toBe('')
+ await expect(location).toHaveCount(0)
await switchPage(chat.page, pages.added)
await stream.evaluate((s) => s.complete())
diff --git a/tests/e2e/storybook/presence/pages.spec.ts b/tests/e2e/storybook/presence/pages.spec.ts
new file mode 100644
index 000000000..fdc900ebc
--- /dev/null
+++ b/tests/e2e/storybook/presence/pages.spec.ts
@@ -0,0 +1,22 @@
+import { expect, test } from '@playwright/test'
+
+const story = (id: string) => `/iframe.html?id=${id}&viewMode=story`
+
+test('page markers name everyone and summarize the overflow', async ({ page }) => {
+ await page.goto(story('collaboration-page-presence-markers--more-than-fit'))
+ const markers = page.getByRole('img', { name: 'Ana, Orbit, Ben, Pixel, Fern' })
+ await expect(markers).toBeVisible()
+ await expect(markers.getByText('+2')).toBeVisible()
+})
+
+test('page markers render nothing for an empty page', async ({ page }) => {
+ await page.goto(story('collaboration-page-presence-markers--nobody'))
+ await expect(page.getByText('Checkout')).toBeVisible()
+ await expect(page.getByRole('img')).toHaveCount(0)
+})
+
+test('the chat names the page its reply works on and offers to go there', async ({ page }) => {
+ await page.goto(story('chat-run-location--on-another-page'))
+ await expect(page.getByRole('status')).toHaveText(/Fern is working on “Checkout”/)
+ await expect(page.getByRole('button', { name: 'Go to page' })).toBeVisible()
+})