feat(app): show who works on each page (#815)
* fix(vue): keep the command palette open when a command opens a step CommandPaletteRoot emitted select for every item, including one that only opens its children, so a host that closes on select closed the palette instead of showing the step. useCommandPalette.select now reports whether a command ran, and the root emits only then. Disabled items were marked only with Reka's data-disabled; expose aria-disabled so assistive technology announces them. * feat(app): jump between pages from the command palette The palette had no way to reach a page. It now lists the pages visited recently in the tab, offers a Go to page step with every page, and finds any page by name. Recent pages are tracked per editor session from page changes and reset when the document is replaced. Palette items can be search-only, so pages beyond the recent ones appear only when the query matches them. The divider-page rule moves out of PageListRoot so the palette skips dividers the same way, and useCommandPalette is exported from the package root. * 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. * feat(app): show who works on each page Agents now publish the page they work on, set when a reply starts on its pinned page and moved by switch_page, so a page is marked before the agent's first edit. presenceByPage groups people and working agents by page. The Pages panel marks those pages with people's dots and agents' outlined sparkles in their owner colors, the command palette names who is on each page, and the chat says which page a reply is working on, with Go to page, while you view another one. * docs(collaboration): list the agent model among shared presence * test(app): stories for page presence markers and the chat's run location The run location notice reads app state, so it moves into useChatRunLocation and the component takes the agent and page as props. * 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): right-align page badges in a trailing area of the page row Presence markers followed the page name. The row now has a trailing area, right-aligned with its own spacing, where markers and later page badges go. * fix(app): key page markers by person or agent, not by name Two people with the same name on a page, such as two Anonymous peers, gave page markers duplicate keys. Entries now carry a stable id.
This commit is contained in:
parent
31e2e84b08
commit
8d132ce070
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ Select a layer, hold <kbd>Option</kbd> on macOS or <kbd>Alt</kbd> on Windows/Lin
|
|||
|
||||
Press <kbd>⌘</kbd><kbd>K</kbd> on macOS or <kbd>Ctrl</kbd> + <kbd>K</kbd> 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,5 +170,7 @@
|
|||
"reasoningWhileThinking": "思考中に展開",
|
||||
"reasoningExpanded": "既定で展開",
|
||||
"jumpToLatest": "最新へ移動",
|
||||
"maxAgentStepsCustom": "カスタム…"
|
||||
"maxAgentStepsCustom": "カスタム…",
|
||||
"chatAgentWorkingOn": "{agent}が「{page}」で作業中",
|
||||
"chatGoToPage": "ページへ移動"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,5 +170,7 @@
|
|||
"reasoningWhileThinking": "Разворачивать во время размышлений",
|
||||
"reasoningExpanded": "Развёрнуто по умолчанию",
|
||||
"jumpToLatest": "К последнему сообщению",
|
||||
"maxAgentStepsCustom": "Своё значение…"
|
||||
"maxAgentStepsCustom": "Своё значение…",
|
||||
"chatAgentWorkingOn": "{agent} работает над «{page}»",
|
||||
"chatGoToPage": "Перейти к странице"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,5 +170,7 @@
|
|||
"reasoningWhileThinking": "思考时展开",
|
||||
"reasoningExpanded": "默认展开",
|
||||
"jumpToLatest": "跳转到最新消息",
|
||||
"maxAgentStepsCustom": "自定义…"
|
||||
"maxAgentStepsCustom": "自定义…",
|
||||
"chatAgentWorkingOn": "{agent} 正在处理“{page}”",
|
||||
"chatGoToPage": "转到页面"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
28
src/app/ai/chat/run-location.ts
Normal file
28
src/app/ai/chat/run-location.ts
Normal file
|
|
@ -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<ChatRunLocation | null>(() => {
|
||||
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) }
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string, PagePresenceEntry[]>,
|
||||
page: SceneNode,
|
||||
extra: Partial<CommandPaletteItem> = {}
|
||||
{ note, ...extra }: Partial<CommandPaletteItem> & { 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 }))
|
||||
]
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string, PagePresenceEntry[]> {
|
||||
const { agents, peers } = presenceOf(store)
|
||||
const byPage = new Map<string, PagePresenceEntry[]>()
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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<Chat<UIMessage> | null>(null)
|
||||
const submission = useChatSubmission({
|
||||
|
|
@ -206,6 +209,12 @@ function handleStop() {
|
|||
<p v-if="history.readOnly.value" role="status" class="px-3 py-2 text-xs text-muted">
|
||||
{{ ai.chatReadOnly }}
|
||||
</p>
|
||||
<ChatRunLocation
|
||||
v-if="runLocation"
|
||||
:agent="runLocation.agent"
|
||||
:page="runLocation.page"
|
||||
@open="runLocation.open"
|
||||
/>
|
||||
<ChatInput
|
||||
v-if="isConfigured && !agentHistoryReadOnly && !history.readOnly.value"
|
||||
:status="status"
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@ import {
|
|||
ContextMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { tv } from 'tailwind-variants'
|
||||
import { ref, watch, type ComponentPublicInstance } from 'vue'
|
||||
import { computed, ref, watch, type ComponentPublicInstance } from 'vue'
|
||||
|
||||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
import { PageListRoot, useFlatReorderDrag, useI18n, useInlineRename } from '@open-pencil/vue'
|
||||
|
||||
import { useActiveEditorStoreRef } from '@/app/editor/active-store'
|
||||
import { presenceByPage } from '@/app/presence/registry'
|
||||
import PresenceMarkers from '@/components/presence/PresenceMarkers.vue'
|
||||
import IconButton from '@/components/ui/button/IconButton.vue'
|
||||
import { useMenuUI } from '@/components/ui/menu/menu'
|
||||
import pageListTheme from '@/theme/page-list'
|
||||
|
|
@ -35,6 +38,11 @@ const menuCls = useMenuUI({
|
|||
const pageListStyles = tv(pageListTheme)
|
||||
const baseStyles = pageListStyles()
|
||||
|
||||
const storeRef = useActiveEditorStoreRef()
|
||||
/** Who works on each page: people in the room and active agents. */
|
||||
const pagePresence = computed(() =>
|
||||
storeRef.value ? presenceByPage(storeRef.value) : new Map<string, never[]>()
|
||||
)
|
||||
const pageActions = ref<Pick<PageActions, 'rename'> | null>(null)
|
||||
const currentPages = ref<readonly PageItem[]>([])
|
||||
const currentMovePage = ref<PageActions['move'] | null>(null)
|
||||
|
|
@ -141,6 +149,9 @@ function setupPageRowRef(
|
|||
>
|
||||
<icon-lucide-file :class="pageStyles(pg, currentPageId).icon()" />
|
||||
<span :class="pageStyles(pg, currentPageId).label()">{{ pg.name }}</span>
|
||||
<span :class="pageStyles(pg, currentPageId).trailing()">
|
||||
<PresenceMarkers :entries="pagePresence.get(pg.id) ?? []" />
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="pageDropPosition(pg) === 'after'"
|
||||
|
|
|
|||
34
src/components/chat/ChatRunLocation.stories.ts
Normal file
34
src/components/chat/ChatRunLocation.stories.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { expect, fn, userEvent, within } from 'storybook/test'
|
||||
|
||||
import ChatRunLocation from './ChatRunLocation.vue'
|
||||
|
||||
type Args = { agent: string; page: string; onOpen: () => 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: '<div class="w-72 bg-panel"><ChatRunLocation v-bind="args" /></div>'
|
||||
})
|
||||
} satisfies Meta<Args>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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' }
|
||||
}
|
||||
21
src/components/chat/ChatRunLocation.vue
Normal file
21
src/components/chat/ChatRunLocation.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
import AppButton from '@/components/ui/button/AppButton.vue'
|
||||
|
||||
const { agent, page } = defineProps<{ agent: string; page: string }>()
|
||||
const emit = defineEmits<{ open: [] }>()
|
||||
|
||||
const { ai } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
role="status"
|
||||
data-test-id="chat-run-location"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-xs text-muted"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{{ ai.chatAgentWorkingOn({ agent, page }) }}</span>
|
||||
<AppButton size="xs" variant="link" @click="emit('open')">{{ ai.chatGoToPage }}</AppButton>
|
||||
</p>
|
||||
</template>
|
||||
70
src/components/presence/PresenceMarkers.stories.ts
Normal file
70
src/components/presence/PresenceMarkers.stories.ts
Normal file
|
|
@ -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:
|
||||
'<div class="flex w-56 items-center gap-2 bg-panel p-3 text-xs text-surface"><span class="flex-1">Checkout</span><PresenceMarkers v-bind="args" /></div>'
|
||||
})
|
||||
} satisfies Meta<Args>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
35
src/components/presence/PresenceMarkers.vue
Normal file
35
src/components/presence/PresenceMarkers.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { colorToCSS } from '@open-pencil/scene-graph/color'
|
||||
|
||||
import type { PagePresenceEntry } from '@/app/presence/registry'
|
||||
import Tip from '@/components/ui/overlay/Tip.vue'
|
||||
import { pageMarkers } from '@/theme/collaboration/page-markers'
|
||||
|
||||
/** How many people and agents a row shows before summarizing the rest as "+N". */
|
||||
const MAX_MARKERS = 3
|
||||
|
||||
const { entries } = defineProps<{ entries: PagePresenceEntry[] }>()
|
||||
|
||||
const ui = pageMarkers()
|
||||
const shown = computed(() => entries.slice(0, MAX_MARKERS))
|
||||
const hidden = computed(() => entries.length - shown.value.length)
|
||||
const names = computed(() => entries.map((entry) => entry.name).join(', '))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tip v-if="entries.length > 0" :label="names">
|
||||
<span data-test-id="presence-markers" :aria-label="names" role="img" :class="ui.root()">
|
||||
<template v-for="entry in shown" :key="entry.id">
|
||||
<icon-lucide-sparkle
|
||||
v-if="entry.kind === 'agent'"
|
||||
:class="ui.agent()"
|
||||
:style="{ color: colorToCSS(entry.color) }"
|
||||
/>
|
||||
<span v-else :class="ui.person()" :style="{ background: colorToCSS(entry.color) }" />
|
||||
</template>
|
||||
<span v-if="hidden > 0" :class="ui.overflow()">+{{ hidden }}</span>
|
||||
</span>
|
||||
</Tip>
|
||||
</template>
|
||||
11
src/theme/collaboration/page-markers.ts
Normal file
11
src/theme/collaboration/page-markers.ts
Normal file
|
|
@ -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'
|
||||
}
|
||||
})
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
22
tests/e2e/storybook/presence/pages.spec.ts
Normal file
22
tests/e2e/storybook/presence/pages.spec.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
Loading…
Reference in a new issue