diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a12a6abb..ec10f8677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - 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. +- See which pages people and AI agents are working on: the Pages panel marks those pages in their colors, the command palette names who is there, and the chat offers **Go to page** while its reply works on a page you're not viewing. - 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. - Choose how much an AI model thinks for each message from the chat composer, from Off to Extra high or the provider default. Anthropic, Google, DeepSeek, OpenAI, OpenRouter, and compatible models apply it, where reasoning effort previously reached only OpenAI and OpenRouter models. A model profile's thinking level, which replaces its free-text reasoning effort, sets the starting choice, and finished reasoning shows how long the model thought. diff --git a/packages/docs/programmable/ai-chat.md b/packages/docs/programmable/ai-chat.md index 65c0717aa..8f428bc64 100644 --- a/packages/docs/programmable/ai-chat.md +++ b/packages/docs/programmable/ai-chat.md @@ -98,6 +98,6 @@ The assistant can verify its work visually. When `export_image` is enabled, it c - Select nodes before asking — the assistant knows what's selected. - Be specific about colors, sizes, and positions for precise results. - The assistant can modify multiple nodes in one message. -- You can browse other pages while a reply runs: the assistant keeps working on the page where the message started, and its previews show when you return. If the assistant switches pages itself, your view follows. +- You can browse other pages while a reply runs: the assistant keeps working on the page where the message started, and its previews show when you return. While you're away, the chat says which page it is working on, with **Go to page** to return. If the assistant switches pages itself, your view follows. - Use "undo" in the editor if you don't like the result — AI mutations support full undo. - All layout is recomputed automatically after each tool execution. diff --git a/packages/docs/programmable/collaboration.md b/packages/docs/programmable/collaboration.md index 39a17f57e..a66e2c231 100644 --- a/packages/docs/programmable/collaboration.md +++ b/packages/docs/programmable/collaboration.md @@ -28,6 +28,8 @@ Click a collaborator's avatar in the top bar to follow their viewport. Your canv 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**. +The share panel lists everyone in the room with the agents they run, what each agent is doing, and on which page. Follow an agent the same way to keep the page and layers it is editing in view; following continues between its replies and stops when it leaves. Double-click one of your own agents to rename it. + ## How It Works Peers connect directly via WebRTC — your design data goes straight from browser to browser, never through a central server. The document state uses a CRDT (conflict-free replicated data type), so concurrent edits merge automatically without conflicts. diff --git a/packages/docs/user-guide/canvas-navigation.md b/packages/docs/user-guide/canvas-navigation.md index ddc190de4..1c5a6b035 100644 --- a/packages/docs/user-guide/canvas-navigation.md +++ b/packages/docs/user-guide/canvas-navigation.md @@ -65,7 +65,7 @@ Select a layer, hold Option on macOS or Alt on Windows/Lin Press ⌘K on macOS or Ctrl + K on Windows/Linux to search editor and application actions. Select a result to run it; unavailable actions remain subject to the current selection and document state. -The palette also moves between pages. Before you type, it lists the pages you visited recently in this tab, most recent first, and **Go to page…** opens a list of every page. Typing a page name finds it too. +The palette also moves between pages. Before you type, it lists the pages you visited recently in this tab, most recent first, and **Go to page…** opens a list of every page. Typing a page name finds it too. Pages where collaborators or AI agents are working list their names; the Pages panel marks the same pages with their colors. ## Tips diff --git a/packages/vue/src/i18n/locales/de/ai.json b/packages/vue/src/i18n/locales/de/ai.json index 3abb92dd4..4c2217016 100644 --- a/packages/vue/src/i18n/locales/de/ai.json +++ b/packages/vue/src/i18n/locales/de/ai.json @@ -170,5 +170,7 @@ "reasoningWhileThinking": "Beim Nachdenken ausklappen", "reasoningExpanded": "Standardmäßig ausgeklappt", "jumpToLatest": "Zum neuesten Beitrag", - "maxAgentStepsCustom": "Eigener Wert…" + "maxAgentStepsCustom": "Eigener Wert…", + "chatAgentWorkingOn": "{agent} arbeitet an „{page}“", + "chatGoToPage": "Zur Seite" } diff --git a/packages/vue/src/i18n/locales/es/ai.json b/packages/vue/src/i18n/locales/es/ai.json index fd2c905cf..658f862e4 100644 --- a/packages/vue/src/i18n/locales/es/ai.json +++ b/packages/vue/src/i18n/locales/es/ai.json @@ -170,5 +170,7 @@ "reasoningWhileThinking": "Expandir al razonar", "reasoningExpanded": "Expandido por defecto", "jumpToLatest": "Ir al último mensaje", - "maxAgentStepsCustom": "Personalizado…" + "maxAgentStepsCustom": "Personalizado…", + "chatAgentWorkingOn": "{agent} está trabajando en «{page}»", + "chatGoToPage": "Ir a la página" } diff --git a/packages/vue/src/i18n/locales/fr/ai.json b/packages/vue/src/i18n/locales/fr/ai.json index 5578e9e03..5039989e8 100644 --- a/packages/vue/src/i18n/locales/fr/ai.json +++ b/packages/vue/src/i18n/locales/fr/ai.json @@ -170,5 +170,7 @@ "reasoningWhileThinking": "Déplier pendant le raisonnement", "reasoningExpanded": "Déplié par défaut", "jumpToLatest": "Aller au dernier message", - "maxAgentStepsCustom": "Personnalisé…" + "maxAgentStepsCustom": "Personnalisé…", + "chatAgentWorkingOn": "{agent} travaille sur « {page} »", + "chatGoToPage": "Aller à la page" } diff --git a/packages/vue/src/i18n/locales/it/ai.json b/packages/vue/src/i18n/locales/it/ai.json index efacb6fe8..915068b69 100644 --- a/packages/vue/src/i18n/locales/it/ai.json +++ b/packages/vue/src/i18n/locales/it/ai.json @@ -170,5 +170,7 @@ "reasoningWhileThinking": "Espandi durante il ragionamento", "reasoningExpanded": "Espanso per impostazione predefinita", "jumpToLatest": "Vai all’ultimo messaggio", - "maxAgentStepsCustom": "Personalizzato…" + "maxAgentStepsCustom": "Personalizzato…", + "chatAgentWorkingOn": "{agent} sta lavorando su «{page}»", + "chatGoToPage": "Vai alla pagina" } diff --git a/packages/vue/src/i18n/locales/ja/ai.json b/packages/vue/src/i18n/locales/ja/ai.json index 1d7e38155..e43bf8342 100644 --- a/packages/vue/src/i18n/locales/ja/ai.json +++ b/packages/vue/src/i18n/locales/ja/ai.json @@ -170,5 +170,7 @@ "reasoningWhileThinking": "思考中に展開", "reasoningExpanded": "既定で展開", "jumpToLatest": "最新へ移動", - "maxAgentStepsCustom": "カスタム…" + "maxAgentStepsCustom": "カスタム…", + "chatAgentWorkingOn": "{agent}が「{page}」で作業中", + "chatGoToPage": "ページへ移動" } diff --git a/packages/vue/src/i18n/locales/pl/ai.json b/packages/vue/src/i18n/locales/pl/ai.json index 1d951e9e4..29901c322 100644 --- a/packages/vue/src/i18n/locales/pl/ai.json +++ b/packages/vue/src/i18n/locales/pl/ai.json @@ -170,5 +170,7 @@ "reasoningWhileThinking": "Rozwijaj podczas rozumowania", "reasoningExpanded": "Domyślnie rozwinięte", "jumpToLatest": "Przejdź do najnowszej wiadomości", - "maxAgentStepsCustom": "Własna wartość…" + "maxAgentStepsCustom": "Własna wartość…", + "chatAgentWorkingOn": "{agent} pracuje nad „{page}”", + "chatGoToPage": "Przejdź do strony" } diff --git a/packages/vue/src/i18n/locales/ru/ai.json b/packages/vue/src/i18n/locales/ru/ai.json index 2fd804946..bc631f90e 100644 --- a/packages/vue/src/i18n/locales/ru/ai.json +++ b/packages/vue/src/i18n/locales/ru/ai.json @@ -170,5 +170,7 @@ "reasoningWhileThinking": "Разворачивать во время размышлений", "reasoningExpanded": "Развёрнуто по умолчанию", "jumpToLatest": "К последнему сообщению", - "maxAgentStepsCustom": "Своё значение…" + "maxAgentStepsCustom": "Своё значение…", + "chatAgentWorkingOn": "{agent} работает над «{page}»", + "chatGoToPage": "Перейти к странице" } diff --git a/packages/vue/src/i18n/locales/zh-cn/ai.json b/packages/vue/src/i18n/locales/zh-cn/ai.json index a570942f8..546aa588e 100644 --- a/packages/vue/src/i18n/locales/zh-cn/ai.json +++ b/packages/vue/src/i18n/locales/zh-cn/ai.json @@ -170,5 +170,7 @@ "reasoningWhileThinking": "思考时展开", "reasoningExpanded": "默认展开", "jumpToLatest": "跳转到最新消息", - "maxAgentStepsCustom": "自定义…" + "maxAgentStepsCustom": "自定义…", + "chatAgentWorkingOn": "{agent} 正在处理“{page}”", + "chatGoToPage": "转到页面" } diff --git a/packages/vue/src/i18n/messages/ai.ts b/packages/vue/src/i18n/messages/ai.ts index 85dea38b5..7e63b21bb 100644 --- a/packages/vue/src/i18n/messages/ai.ts +++ b/packages/vue/src/i18n/messages/ai.ts @@ -37,6 +37,8 @@ export const aiMessageDefaults = { chatStorageFailed: 'Chat history could not be saved. Keep this window open and try again.', chatHistoryFailed: 'Could not update chat history. Try again.', chatInterrupted: 'This response was interrupted.', + chatAgentWorkingOn: params('{agent} is working on “{page}”'), + chatGoToPage: 'Go to page', connectProvider: 'Connect an AI provider to start chatting.', connectionTestSuccess: 'Connected successfully. Model is reachable.', connectionTestMissingAPIKey: 'Enter an API key before testing.', diff --git a/src/app/ai/chat/run-location.ts b/src/app/ai/chat/run-location.ts new file mode 100644 index 000000000..c3600b7cb --- /dev/null +++ b/src/app/ai/chat/run-location.ts @@ -0,0 +1,28 @@ +import { computed } from 'vue' + +import { runAgentId } from '@/app/ai/tools' +import { presenceOf } from '@/app/presence/registry' +import { activeTab } from '@/app/tabs' + +/** The page the chat's reply works on, while you look at a different one. */ +export interface ChatRunLocation { + agent: string + page: string + open: () => void +} + +export function useChatRunLocation() { + return computed(() => { + const tab = activeTab.value + if (!tab || tab.kind === 'home') return null + const store = tab.store + const agentId = runAgentId(store) + const agent = presenceOf(store).agents.value.find((entry) => entry.id === agentId) + if (!agent || agent.status === 'idle' || !agent.pageId) return null + const pageId = agent.pageId + if (pageId === store.state.currentPageId) return null + const page = store.graph.getNode(pageId)?.name + if (!page) return null + return { agent: agent.name, page, open: () => void store.switchPage(pageId) } + }) +} diff --git a/src/app/ai/tools/index.ts b/src/app/ai/tools/index.ts index 9c358c751..b78cf6382 100644 --- a/src/app/ai/tools/index.ts +++ b/src/app/ai/tools/index.ts @@ -29,7 +29,7 @@ import { stepBudget } from './run' -export { didHitStepLimit, endRun, recordStep, runPageId, startRun } from './run' +export { didHitStepLimit, endRun, recordStep, runAgentId, runPageId, startRun } from './run' export function createAITools(store: EditorStore, diagnosticContext?: AIDiagnosticContext) { const acquireMutation = createMutex() diff --git a/src/app/ai/tools/run.ts b/src/app/ai/tools/run.ts index 885c2d5cb..c5cf2f1fa 100644 --- a/src/app/ai/tools/run.ts +++ b/src/app/ai/tools/run.ts @@ -46,7 +46,7 @@ export function startRun(store: EditorStore, maxSteps: number, model?: string): const run = getRunState(store) run.start(maxSteps, store.state.currentPageId) run.agent ??= addAgent(store, 'chat', model) - run.agent.update({ status: 'thinking', model }) + run.agent.update({ status: 'thinking', model, pageId: store.state.currentPageId }) } /** The reply finished, failed, or was stopped: the agent stays listed but leaves the canvas. */ @@ -89,10 +89,17 @@ export function runPageId(store: EditorStore): string { /** Move the run to `pageId`, and the user's view with it, as the agent's `switch_page` does. */ export async function moveRunToPage(store: EditorStore, pageId: string): Promise { - getRunState(store).pageId = pageId + const run = getRunState(store) + run.pageId = pageId + run.agent?.update({ pageId }) if (store.state.currentPageId !== pageId) await store.switchPage(pageId) } +/** The built-in chat's agent in this document, once it has replied. */ +export function runAgentId(store: EditorStore): string | undefined { + return getRunState(store).agent?.id +} + /** Keep `snapshot` as the run's starting state of its page unless the run already edited it. */ export function recordRunBaseline(store: EditorStore, snapshot: PageSnapshot): void { const pageId = snapshot.values().next().value?.id diff --git a/src/app/editor/pages/palette.ts b/src/app/editor/pages/palette.ts index 965394979..f345aae43 100644 --- a/src/app/editor/pages/palette.ts +++ b/src/app/editor/pages/palette.ts @@ -12,6 +12,7 @@ import { } from '@open-pencil/vue' import type { EditorStore } from '@/app/editor/active-store' +import { presenceByPage, type PagePresenceEntry } from '@/app/presence/registry' import { activeTab } from '@/app/tabs' /** How many recent pages the unfiltered palette lists. */ @@ -19,12 +20,16 @@ const PALETTE_RECENT_PAGES = 5 function pageItem( store: EditorStore, + presence: Map, page: SceneNode, - extra: Partial = {} + { note, ...extra }: Partial & { note?: string } = {} ): CommandPaletteItem { + // Who works on the page follows the note, so "Recent · Fern, Ana". + const people = (presence.get(page.id) ?? []).map((entry) => entry.name).join(', ') return { id: `page:${page.id}`, label: page.name, + description: [note, people].filter(Boolean).join(' · ') || undefined, icon: IconFile, onSelect: () => void store.switchPage(page.id), ...extra @@ -52,24 +57,30 @@ export function usePagePaletteGroup() { compact(without(store.recentPages.value, current).map((id) => byId[id])), PALETTE_RECENT_PAGES ) + const presence = presenceByPage(store) const others = without(difference(pages, recent), byId[current]) return { id: 'pages', label: messages.value.pages, items: [ - ...recent.map((page) => pageItem(store, page, { description: messages.value.recentPage })), + ...recent.map((page) => + pageItem(store, presence, page, { note: messages.value.recentPage }) + ), { id: 'pages:go-to', label: messages.value.goToPage, icon: IconFiles, children: pages.map((page) => page.id === current - ? pageItem(store, page, { description: messages.value.currentPage, disabled: true }) - : pageItem(store, page) + ? pageItem(store, presence, page, { + note: messages.value.currentPage, + disabled: true + }) + : pageItem(store, presence, page) ) }, - ...others.map((page) => pageItem(store, page, { searchOnly: true })) + ...others.map((page) => pageItem(store, presence, page, { searchOnly: true })) ] } }) diff --git a/src/app/presence/registry.ts b/src/app/presence/registry.ts index ff67f752d..d7f797213 100644 --- a/src/app/presence/registry.ts +++ b/src/app/presence/registry.ts @@ -59,6 +59,59 @@ export function presenceOf(store: EditorStore): Presence { return presence } +function localAgentColor(store: EditorStore): Color { + return presenceOf(store).ownerColor.value ?? SOLO_AGENT_COLOR +} + +/** Someone working on a page, as page lists show them. */ +export interface PagePresenceEntry { + /** Stable per person or agent; names can repeat, such as two people named Anonymous. */ + id: string + kind: 'person' | 'agent' + name: string + color: Color +} + +function agentPage(agent: AgentPresence): string | undefined { + return agent.status === 'idle' ? undefined : (agent.pageId ?? agent.cursor?.pageId) +} + +/** + * People and working agents by page. Reads reactive state, so computed values that call it + * update as people move and agents start and stop. + */ +export function presenceByPage(store: EditorStore): Map { + const { agents, peers } = presenceOf(store) + const byPage = new Map() + const add = (pageId: string | undefined, entry: PagePresenceEntry) => { + if (pageId) byPage.set(pageId, [...(byPage.get(pageId) ?? []), entry]) + } + for (const peer of peers.value) { + add(peer.cursor?.pageId, { + id: `person:${peer.clientId}`, + kind: 'person', + name: peer.name, + color: peer.color + }) + for (const agent of peer.agents) + add(agentPage(agent), { + id: `agent:${agent.id}`, + kind: 'agent', + name: agent.name, + color: peer.color + }) + } + for (const agent of agents.value) { + add(agentPage(agent), { + id: `agent:${agent.id}`, + kind: 'agent', + name: agent.name, + color: localAgentColor(store) + }) + } + return byPage +} + function agentCursor(agent: AgentPresence, color: Color, pageId: string): PresenceCursor[] { if (agent.status === 'idle' || agent.cursor?.pageId !== pageId) return [] const { x, y } = agent.cursor @@ -67,7 +120,7 @@ function agentCursor(agent: AgentPresence, color: Color, pageId: string): Presen /** Draw everyone working on the page on screen: people, their agents, and ours. */ export function refreshCursors(store: EditorStore): void { - const { agents, peers, ownerColor } = presenceOf(store) + const { agents, peers } = presenceOf(store) const pageId = store.state.currentPageId store.state.presenceCursors = [ ...peers.value.flatMap((peer): PresenceCursor[] => [ @@ -84,9 +137,7 @@ export function refreshCursors(store: EditorStore): void { : []), ...peer.agents.flatMap((agent) => agentCursor(agent, peer.color, pageId)) ]), - ...agents.value.flatMap((agent) => - agentCursor(agent, ownerColor.value ?? SOLO_AGENT_COLOR, pageId) - ) + ...agents.value.flatMap((agent) => agentCursor(agent, localAgentColor(store), pageId)) ] store.requestRepaint() keepFollowing(store) diff --git a/src/app/presence/schema.ts b/src/app/presence/schema.ts index 4690f401a..419d39d2c 100644 --- a/src/app/presence/schema.ts +++ b/src/app/presence/schema.ts @@ -32,6 +32,7 @@ const agent = v.object({ kind: v.picklist(AGENT_KINDS), model: lenient(v.pipe(v.string(), v.maxLength(MAX_NAME_LENGTH))), status: v.picklist(AGENT_STATUSES), + pageId: lenient(id), cursor: lenient(point), selection: lenient(selection) }) diff --git a/src/app/presence/types.ts b/src/app/presence/types.ts index 38b90f10f..6040e9c73 100644 --- a/src/app/presence/types.ts +++ b/src/app/presence/types.ts @@ -26,6 +26,8 @@ export interface AgentPresence { kind: AgentKind model?: string status: AgentStatus + /** The page the agent works on, known before its first edit. */ + pageId?: string /** Where the agent last worked, derived from the nodes it touched. */ cursor?: PresencePoint selection?: string[] diff --git a/src/components/ChatPanel.vue b/src/components/ChatPanel.vue index 2d4b50b40..44266cb40 100644 --- a/src/components/ChatPanel.vue +++ b/src/components/ChatPanel.vue @@ -6,6 +6,7 @@ import { computed, shallowRef, watch } from 'vue' import { useI18n } from '@open-pencil/vue' import { chatDocumentId } from '@/app/ai/chat/history/document' +import { useChatRunLocation } from '@/app/ai/chat/run-location' import { useChatSubmission } from '@/app/ai/chat/submission/use' import { useAIChat } from '@/app/ai/chat/use' import { didHitStepLimit } from '@/app/ai/tools' @@ -16,11 +17,13 @@ import { activeTab } from '@/app/tabs' import ACPPermissionDialog from '@/components/chat/ACPPermissionDialog.vue' import ChatHistory from '@/components/chat/ChatHistory.vue' import ChatInput from '@/components/chat/ChatInput.vue' +import ChatRunLocation from '@/components/chat/ChatRunLocation.vue' import ChatTranscript from '@/components/chat/ChatTranscript.vue' import ProviderSetup from '@/components/chat/ProviderSetup.vue' const { isConfigured, ensureChat, history, chatFailure, clearChatFailure } = useAIChat() const { ai } = useI18n() +const runLocation = useChatRunLocation() const chat = shallowRef | null>(null) const submission = useChatSubmission({ @@ -206,6 +209,12 @@ function handleStop() {

{{ ai.chatReadOnly }}

+ + storeRef.value ? presenceByPage(storeRef.value) : new Map() +) const pageActions = ref | null>(null) const currentPages = ref([]) const currentMovePage = ref(null) @@ -141,6 +149,9 @@ function setupPageRowRef( > {{ pg.name }} + + +
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 @@ + + + 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: + '
Checkout
' + }) +} 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 @@ + + + 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() +})