diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 63886dd3..b860f1f2 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -69,6 +69,10 @@ pub fn bundled_manifests() -> Vec { .expect("projects manifest"), serde_json::from_str(include_str!("../../../src/bundled/agents/manifest.json")) .expect("agents manifest"), + serde_json::from_str(include_str!( + "../../../src/bundled/agent-channels/manifest.json" + )) + .expect("agent channels manifest"), ] } fn is_bundled(id: &str) -> bool { diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index 678b7f06..f357f675 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -276,6 +276,7 @@ fn bundled_plugins_have_independent_flags_and_all_ids_are_reserved() { "buzz.bestie", "buzz.projects", "buzz.agents", + "buzz.agent-channels", "buzz.emoji", "buzz.mentions", ] { diff --git a/docs/agent-channels.md b/docs/agent-channels.md new file mode 100644 index 00000000..63b61941 --- /dev/null +++ b/docs/agent-channels.md @@ -0,0 +1,89 @@ +# Agent dashboard: selected-community relationship view + +## First slice + +Agent dashboard is a bundled page plugin that gives an agent-first view of +currently selected community where an identity from the current Buzz agent +library is a member now or has authored activity returned by a finite verified +relay read. It is a relationship view, not a directory, ownership proof, +runtime monitor, or cross-community index. + +The plugin injects `pages`, `relay`, and the shared external-object provider +registry. It reuses the session-owned agent library, identity archive evidence, +channel roster, verified finite reader and media helper. It does not create a +relay connection, cache, outbox or host route. Switching community or +reconnecting remounts its session-owned subtree using scope plus generation. + +## Relationship model + +`bundled/agent-channels/relationships.ts` produces three display-independent +records: + +- agent nodes group exact non-archived library identity pubkeys by saved agent + definition; definitions without an active linked identity are omitted; +- channel nodes retain channel ID, name, relay-authored type and archive state; +- edges retain exact agent/channel IDs, current/past/unknown membership, first + and last observed activity, and observed message count. + +Current membership comes only from the channel summary's relay-signed exact +member list. Activity comes only from verified or relay-accepted kind-9 events +authored by exact library identity pubkeys and carrying an authorized channel's +`h` tag; pending, unknown-delivery and failed local outbox events are excluded. +Display names never establish a relationship. + +## Dashboard projection + +The page projects the relationship records into one row per agent, ordered by +current channel membership and recent observed activity. Selecting an agent +centers its avatar in a bounded relationship map: channel nodes form the first +ring, and other agent avatars connect through channels they share with the +selected agent. The map deliberately limits visible nodes for legibility; the +selected agent's full channel list remains available below it and supplies the +non-visual equivalent for keyboard, touch and narrow layouts. + +The dashboard summary retains current/past/unknown membership, sampled message +count and last observed activity. It does not infer presence, running state, +task status or complete lifetime work from messages. + +## Pull request outcomes + +The optional **Outcomes** view extracts canonical pull request links directly +from eligible agent-authored activity and retains each signed message ID, +channel, agent and share timestamp as association evidence. Repeated shares of +the same pull request are grouped rather than double-counted. + +External details come through `features/objects`, a typed contribution registry +that is separate from panel presentation. The GitHub plugin registers both its +existing panel and an object provider, so Agent dashboard never imports GitHub +implementation code. The provider supplies current public state, title, author, +branch and change facts with bounded four-at-a-time loading; disabling GitHub +removes both capabilities. Private or unavailable GitHub objects remain visible +from their signed Buzz reference with a truthful enrichment error. + +A shared link establishes observable association, not pull request authorship or +causation. The UI therefore says **shared by** and **associated work**, never +claims that an agent authored a pull request or caused its merge. + +## Bounds and truthful states + +Activity uses background-priority finite reads in bounded author and authorized- +channel batches. A shared 2,000-event request budget is allocated across all +batches, then the eligible results are merged by recency. The relationship +projection is separately capped at 10,000 edges and reports when that display +bound is reached. The read contract +does not return a historical completeness bound, so the page says **observed** +activity and never claims complete lifetime history. A channel omitted from the +current authorized roster is never displayed from activity alone. + +The page exposes disconnected, connecting, unavailable library, loading, empty, +partial roster, failed update and retry states. Existing edges remain visible +when a later update fails. Archive evidence affects which saved identities are +shown; it is not channel access evidence. + +## Validation + +The pure projection tests cover exact identity grouping, current/past/unknown +edges, authorized-channel filtering and shared-reader inputs. App composition +covers registration, disable/re-enable and relay-session lifetime. The native +plugin manager catalogs the manifest independently so Settings and CLI reserve +and enable the same bundled ID as the browser runtime. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 70c2b641..59962173 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -42,12 +42,14 @@ app/ host, startup, navigation, Settings plugins/ installation, lifecycle, contribution ownership features/pages/ page contract and host rendering features/panels/ target resolution, launcher contract and reusable card/frame +features/objects/ typed external-object identification and data loading features/shortcuts/ in-app binding dispatch, focus rules and plugin ownership features/relay/ shared channel data, queries, profiles and durable delivery features/messages/ reusable timeline, message, thread and composer UI bundled/channels/ Channels navigation, sidebar, page layout and panel placement bundled/projects/ title-only Projects page scaffold bundled/agents/ read-only current-Buzz agent library page +bundled/agent-channels/ agent-first dashboard and relationship map features/agents/ shared session-owned local library view bundled/github/ builtin GitHub panel plugin bundled/bestie/ builtin companion panel and its snake launcher @@ -83,16 +85,20 @@ and returns false after the originating opening, channel, session or host presentation retires. This is not a global navigation API or an access grant. Channel-header launchers use the separate public `channelContext` metadata contract described below; launcher/fallback panels need not have either context. -A plugin that needs shared data declares `relay` in its -injection list and passes those capabilities to its components using a closure, -just as the bundled Channels page does. The conversation preview adds only the two demonstrated component surfaces. +A page that needs integration facts without panel UI uses +`objects.resolve(target)` / `objects.load(target, signal)`; providers own +recognition and bounded external loading, and disappear when their plugin is +disabled. A plugin that needs shared data declares `relay` in its injection list +and passes those capabilities to its components using a closure, just as the +bundled Channels page does. The conversation preview adds only the two +demonstrated component surfaces. Channels owns its selected channel and docked target. The panel view isolates render failures and remounts on target or revision changes. Unloading a plugin removes its contributions and closes its panel. Other pages can use these same contracts with their own layout and local navigation. -The initial distribution contains Channels, Projects, Agents, GitHub, Bestie, Emoji, Mentions, Profiles and Terminal. Projects +The initial distribution contains Channels, Projects, Agents, Agent dashboard, GitHub, Bestie, Emoji, Mentions, Profiles and Terminal. Projects is an enabled-by-default scaffold with only a centered title and no relay dependency. GitHub recognizes repository, pull request, issue, and commit URLs and loads public object details on demand. diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index f9b0f0f5..89c3f5e9 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -29,7 +29,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", services = createServices(); assert.deepEqual(services.pages.snapshot(), []); await settle(); - assert.equal(services.pages.snapshot().length, 3); + assert.equal(services.pages.snapshot().length, 4); await vi.waitFor(() => assert.equal(services.conversation.tools.snapshot().length, 2), ); @@ -110,7 +110,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", .some((panel) => panel.pluginId === "buzz.bestie"), false, ); - assert.equal(services.pages.snapshot().length, 3); + assert.equal(services.pages.snapshot().length, 4); await services.plugins.change("enable", "buzz.bestie"); // Management completion is not activation completion; Cordis still owns import/disposal barriers. await vi.waitFor(() => @@ -139,6 +139,31 @@ test("the app runtime exposes ready bundled pages and removes them on disable", /Connect to a community/, ); const session = services.relay.snapshot().session; + const agentChannels = services.pages + .snapshot() + .find((page) => page.pluginId === "buzz.agent-channels"); + assert.equal(agentChannels.title, "Agent dashboard"); + assert.match( + renderToStaticMarkup(createElement(agentChannels.component)), + /Connect to a community to see where your agents are working/, + ); + await services.plugins.change("disable", "buzz.agent-channels"); + assert.equal( + services.pages + .snapshot() + .some((page) => page.pluginId === "buzz.agent-channels"), + false, + ); + assert.equal(services.relay.snapshot().session, session); + await services.plugins.change("enable", "buzz.agent-channels"); + await vi.waitFor(() => + assert.ok( + services.pages + .snapshot() + .some((page) => page.pluginId === "buzz.agent-channels"), + ), + ); + await services.plugins.change("disable", "buzz.agent-channels"); await services.plugins.change("disable", "buzz.agents"); assert.equal( services.pages.snapshot().some((page) => page.pluginId === "buzz.agents"), diff --git a/src/app/services.ts b/src/app/services.ts index 8b762229..1bf4c686 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -5,6 +5,7 @@ import { ConversationService } from "../features/conversation/service"; import { createAppearance } from "../shared/theme/service"; import { createCommunities } from "../features/communities/service"; import { PanelsService } from "../features/panels/service"; +import { ObjectsService } from "../features/objects/service"; import { Context } from "@deepseek-ai/cordis"; import { PagesService } from "../features/pages/service"; import { bundledPlugins } from "../bundled"; @@ -22,6 +23,7 @@ export function createServices() { const shortcuts = new ShortcutsService(ctx); const pages = new PagesService(ctx); const panels = new PanelsService(ctx); + const objects = new ObjectsService(ctx); const conversation = new ConversationService(ctx); const communities = createCommunities( ctx, @@ -36,6 +38,7 @@ export function createServices() { conversation, pages, panels, + objects, plugins, relay, communities, diff --git a/src/bundled/agent-channels/AgentChannelsPage.tsx b/src/bundled/agent-channels/AgentChannelsPage.tsx new file mode 100644 index 00000000..7116387a --- /dev/null +++ b/src/bundled/agent-channels/AgentChannelsPage.tsx @@ -0,0 +1,1646 @@ +import { + Check, + ExternalLink, + GitBranch, + GitMerge, + GitPullRequest, + History, + Network, + RefreshCw, + Rocket, + Search, + Sparkles, + UsersRound, +} from "lucide-react"; +import { + type CSSProperties, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { + ExternalObjectDetails, + Objects, +} from "../../features/objects/service"; +import type { AgentLibrary } from "../../features/agents/library"; +import { useChannelList, useRelayConnection } from "../../features/relay/react"; +import type { RelayData } from "../../features/relay/service"; +import type { RelaySession } from "../../features/relay/session"; +import { Avatar } from "../../shared/Avatar"; +import { avatarSource } from "../../shared/avatar-source"; +import { + buildAgentDashboard, + buildAgentFocusNetwork, + type AgentDashboardRow, + type AgentFocusNetwork, +} from "./dashboard"; +import { extractAgentOutcomes, type AgentOutcomeReference } from "./outcomes"; +import { + agentNodes, + buildAgentChannelGraph, + GRAPH_EDGE_LIMIT, + HISTORY_EVENT_LIMIT, + readAgentActivity, + type ChannelNode, +} from "./relationships"; + +export function AgentChannelsPage({ + relay, + objects, +}: { + relay: RelayData; + objects: Objects; +}) { + const connection = useRelayConnection(relay); + return ( +
+ {connection.status === "ready" ? ( + + ) : ( + + )} +
+ ); +} + +function ConnectionState({ + relay, + status, +}: { + relay: RelayData; + status: "disconnected" | "connecting" | "error"; +}) { + return ( +
+
+ ); +} + +type ActivityState = Readonly<{ + status: "idle" | "loading" | "ready" | "error"; + events: Awaited>; + error?: string; +}>; +const noActivity: ActivityState["events"] = Object.freeze([]); +const noLibrary: AgentLibrary = Object.freeze({ + definitions: Object.freeze([]), + identities: Object.freeze([]), +}); + +function AgentDashboard({ + session, + objects, +}: { + session: RelaySession; + objects: Objects; +}) { + const library = useSyncExternalStore( + session.agentLibrary.subscribe, + session.agentLibrary.snapshot, + session.agentLibrary.snapshot, + ); + const archives = useSyncExternalStore( + session.archives.subscribe, + session.archives.snapshot, + session.archives.snapshot, + ); + const objectProviders = useSyncExternalStore( + objects.subscribe, + objects.snapshot, + objects.snapshot, + ); + const providerRevision = objectProviders + .map((provider) => provider.key) + .join("\n"); + const channels = useChannelList(session.channels); + const retainedLibrary = useRef(noLibrary); + if (library.status === "ready") retainedLibrary.current = library; + const displayedLibrary = + library.status === "ready" ? library : retainedLibrary.current; + const [activity, setActivity] = useState({ + status: "idle", + events: noActivity, + }); + const activityRef = useRef(activity); + activityRef.current = activity; + const [activityAttempt, setActivityAttempt] = useState(0); + const [query, setQuery] = useState(""); + const [selectedAgentId, setSelectedAgentId] = useState(); + const [view, setView] = useState<"map" | "outcomes">("map"); + + useEffect(() => { + void session.agentLibrary.refresh(); + void session.archives.refresh(); + }, [session]); + + const archivedPubkeys = useMemo(() => new Set(archives.archived), [archives]); + const agents = useMemo( + () => agentNodes(displayedLibrary, (pubkey) => archivedPubkeys.has(pubkey)), + [displayedLibrary, archivedPubkeys], + ); + const pubkeys = useMemo( + () => agents.flatMap((agent) => agent.identityPubkeys), + [agents], + ); + const channelIds = useMemo( + () => channels.channels.map((channel) => channel.id), + [channels.channels], + ); + + useEffect(() => { + void activityAttempt; + if (library.status !== "ready" || channels.status !== "ready") return; + if (!pubkeys.length || !channelIds.length) { + setActivity({ status: "idle", events: noActivity }); + return; + } + const controller = new AbortController(); + setActivity({ status: "loading", events: activityRef.current.events }); + void readAgentActivity( + session, + pubkeys, + channelIds, + controller.signal, + ).then( + (events) => { + if (!controller.signal.aborted) + setActivity({ status: "ready", events }); + }, + (error) => { + if (!controller.signal.aborted) + setActivity({ + status: "error", + events: activityRef.current.events, + error: error instanceof Error ? error.message : String(error), + }); + }, + ); + return () => controller.abort(); + }, [ + session, + library.status, + channels.status, + pubkeys, + channelIds, + activityAttempt, + ]); + + const graph = useMemo( + () => + buildAgentChannelGraph( + channels.channels, + agents, + activity.events, + channels.coverage !== "partial" && channels.status === "ready", + ), + [ + channels.channels, + channels.coverage, + channels.status, + agents, + activity.events, + ], + ); + const dashboard = useMemo(() => buildAgentDashboard(graph), [graph]); + const filteredDashboard = useMemo(() => { + const needle = query.trim().toLocaleLowerCase(); + if (!needle) return dashboard; + return dashboard.filter( + (row) => + row.agent.name.toLocaleLowerCase().includes(needle) || + row.relationships.some(({ channel }) => + channel.name.toLocaleLowerCase().includes(needle), + ), + ); + }, [dashboard, query]); + const effectiveAgentId = filteredDashboard.some( + (row) => row.agent.id === selectedAgentId, + ) + ? selectedAgentId + : filteredDashboard[0]?.agent.id; + const focus = useMemo( + () => + effectiveAgentId + ? buildAgentFocusNetwork(dashboard, effectiveAgentId) + : undefined, + [dashboard, effectiveAgentId], + ); + const relationshipChannelCount = useMemo( + () => new Set(graph.edges.map((edge) => edge.channelId)).size, + [graph.edges], + ); + const observedMessageCount = dashboard.reduce( + (total, row) => total + row.observedMessageCount, + 0, + ); + const outcomeReferences = useMemo(() => { + if (!providerRevision) return Object.freeze([]); + return extractAgentOutcomes( + activity.events, + graph.agents, + graph.channels, + objects, + ); + }, [ + activity.events, + graph.agents, + graph.channels, + objects, + providerRevision, + ]); + const updateError = library.error ?? channels.error ?? activity.error; + const loading = + library.status === "loading" || + channels.status === "loading" || + activity.status === "loading"; + const refresh = () => { + setActivityAttempt((attempt) => attempt + 1); + void session.agentLibrary.refresh(); + void session.archives.refresh(); + session.channels.refreshList?.(); + }; + const hasRelationships = graph.edges.length > 0; + + return ( +
+
+
+

CURRENT COMMUNITY

+

+ Agent dashboard +

+

+ A bird’s-eye view of where your agents work, what we observed, and + where their paths cross. +

+

+ {dashboard.length} agents · {relationshipChannelCount} channels ·{" "} + {observedMessageCount.toLocaleString()} messages sampled +

+
+ +
+ +
+
+
+ + {loading && ( +

+ Updating the map… +

+ )} +
+ + {updateError && hasRelationships && ( +
+

+ Showing the dashboard already found, but the latest update did not + finish. {updateError} +

+ +
+ )} + + 0} + hasRelationships={hasRelationships} + retry={refresh} + /> + + {hasRelationships && view === "map" && ( + <> +
+
+
+

YOUR AGENTS

+

+ Overview for +

+
+ + {filteredDashboard.length} of {dashboard.length} + +
+ {filteredDashboard.length ? ( + <> + +
+ {filteredDashboard.map((row) => ( + setSelectedAgentId(row.agent.id)} + /> + ))} +
+ + ) : ( + + )} +
+ + {focus && ( +
+
+
+

RELATIONSHIP MAP

+

+ Agent network +

+
+

+ Agent → channel → collaborator +

+
+ + +
+ )} + + )} + + {hasRelationships && + view === "outcomes" && + (providerRevision ? ( + { + setSelectedAgentId(agentId); + setView("map"); + }} + /> + ) : ( +
+
+ ))} + + {hasRelationships && channels.coverage === "partial" && ( + + The community roster is partial, so membership is shown as unknown and + additional relationships may be missing. + + )} + {hasRelationships && graph.edgesLimited && ( + + The dashboard is limited to the {GRAPH_EDGE_LIMIT.toLocaleString()}{" "} + most relevant agent-channel relationships. + + )} + {hasRelationships && activity.events.length >= HISTORY_EVENT_LIMIT && ( + + Message totals are a recent sample of up to{" "} + {HISTORY_EVENT_LIMIT.toLocaleString()} eligible messages across this + community—not lifetime totals. + + )} + {hasRelationships && archives.status !== "ready" && ( + + Archive visibility is unavailable. Saved library identities remain + visible; this does not grant channel access. + + )} +
+ ); +} + +type LoadedOutcome = Readonly<{ + outcome: AgentOutcomeReference; + details?: ExternalObjectDetails; + error?: string; +}>; + +function ViewTab({ + active, + icon, + label, + count, + onSelect, +}: { + active: boolean; + icon: React.ReactNode; + label: string; + count?: number | undefined; + onSelect: () => void; +}) { + return ( + + ); +} + +function OutcomesDashboard({ + references, + objects, + dashboard, + channels, + session, + query, + onSelectAgent, +}: { + references: readonly AgentOutcomeReference[]; + objects: Objects; + dashboard: readonly AgentDashboardRow[]; + channels: readonly ChannelNode[]; + session: RelaySession; + query: string; + onSelectAgent: (agentId: string) => void; +}) { + const [loaded, setLoaded] = useState([]); + const [status, setStatus] = useState<"idle" | "loading" | "ready">("idle"); + const [selectedKey, setSelectedKey] = useState(); + useEffect(() => { + if (!references.length) { + setLoaded([]); + setStatus("idle"); + return; + } + const controller = new AbortController(); + setStatus("loading"); + void loadOutcomeDetails(references, objects, controller.signal).then( + (next) => { + if (!controller.signal.aborted) { + setLoaded(next); + setStatus("ready"); + } + }, + () => { + if (!controller.signal.aborted) setStatus("ready"); + }, + ); + return () => controller.abort(); + }, [objects, references]); + + const agents = useMemo( + () => new Map(dashboard.map((row) => [row.agent.id, row])), + [dashboard], + ); + const channelNames = useMemo( + () => new Map(channels.map((channel) => [channel.id, channel.name])), + [channels], + ); + const needle = query.trim().toLocaleLowerCase(); + const visible = loaded.filter(({ outcome, details }) => { + if (!needle) return true; + return ( + details?.title.toLocaleLowerCase().includes(needle) || + outcome.reference.group?.toLocaleLowerCase().includes(needle) || + outcome.evidence.some((evidence) => + agents + .get(evidence.agentId) + ?.agent.name.toLocaleLowerCase() + .includes(needle), + ) + ); + }); + const merged = loaded.filter( + (item) => item.details?.state === "Merged", + ).length; + const open = loaded.filter((item) => item.details?.state === "open").length; + const draft = loaded.filter((item) => item.details?.state === "Draft").length; + const repositoryCount = new Set( + loaded.flatMap(({ outcome }) => outcome.reference.group ?? []), + ).size; + const selected = + visible.find((item) => item.outcome.key === selectedKey) ?? visible[0]; + + if (!references.length) + return ( +
+
+ ); + + return ( +
+
+
+
+
+

+

+

+ Outcomes +

+

+ Pull requests your agents shared, enriched with their latest + public GitHub state. Association is backed by the signed Buzz + message. +

+
+ {status === "loading" && ( +

+ Following branches to GitHub… +

+ )} +
+
+ } + value={merged} + label="merged" + tone="success" + /> + } + value={open} + label="open" + /> + } + value={draft} + label="draft" + /> + } + value={repositoryCount} + label="repositories" + /> +
+
+ + {status === "ready" && visible.length === 0 ? ( + + ) : ( +
+
+ setSelectedKey(key)} + /> +
+
+

All pull requests

+ + {visible.length} outcomes + +
+
+ {visible.map((item) => ( + setSelectedKey(item.outcome.key)} + /> + ))} +
+
+
+ {selected && ( + + )} +
+ )} +

+ Public GitHub data only. “Shared by” proves observable association—not + PR authorship or that an agent caused the merge. +

+
+ ); +} + +function OutcomeFlow({ + items, + selected, + agents, + channelNames, + session, + onSelect, +}: { + items: readonly LoadedOutcome[]; + selected: LoadedOutcome | undefined; + agents: Map; + channelNames: Map; + session: RelaySession; + onSelect: (key: string) => void; +}) { + return ( +
+
+
+

Shipping paths

+

+ Follow each signed share from agent to GitHub outcome. +

+
+
+ + Landed + + + + In flight + +
+
+
+
+
+ Agent signal + Channel + Branch + GitHub outcome +
+
+ {items.length ? ( + items.map((item) => ( + onSelect(item.outcome.key)} + /> + )) + ) : ( +
+ Tracing shared pull requests… +
+ )} +
+
+
+
+ ); +} + +function OutcomePath({ + item, + selected, + agents, + channelNames, + session, + onSelect, +}: { + item: LoadedOutcome; + selected: boolean; + agents: Map; + channelNames: Map; + session: RelaySession; + onSelect: () => void; +}) { + const evidence = item.outcome.evidence[0]; + const uniqueAgentIds = [ + ...new Set(item.outcome.evidence.map((item) => item.agentId)), + ]; + const uniqueChannelIds = [ + ...new Set(item.outcome.evidence.map((item) => item.channelId)), + ]; + const state = item.details?.state; + const merged = state === "Merged"; + const facts = new Map(item.details?.facts ?? []); + const title = + item.details?.title ?? + `${item.outcome.reference.group} ${item.outcome.reference.label}`; + return ( + + ); +} + +async function loadOutcomeDetails( + references: readonly AgentOutcomeReference[], + objects: Objects, + signal: AbortSignal, +): Promise { + const results: LoadedOutcome[] = []; + const queue = [...references]; + await Promise.all( + Array.from({ length: Math.min(4, queue.length) }, async () => { + while (queue.length) { + signal.throwIfAborted(); + const outcome = queue.shift(); + if (!outcome) return; + try { + const details = await objects.load(outcome.reference.url, signal); + results.push( + Object.freeze({ outcome, ...(details ? { details } : {}) }), + ); + } catch (error) { + if (signal.aborted) throw error; + results.push( + Object.freeze({ + outcome, + error: error instanceof Error ? error.message : String(error), + }), + ); + } + } + }), + ); + const order = new Map( + references.map((outcome, index) => [outcome.key, index]), + ); + return Object.freeze( + results.sort( + (a, b) => + (order.get(a.outcome.key) ?? 0) - (order.get(b.outcome.key) ?? 0), + ), + ); +} + +function OutcomeMetric({ + icon, + value, + label, + tone, +}: { + icon: React.ReactNode; + value: number; + label: string; + tone?: "success"; +}) { + return ( +
+
+ {icon} {label} +
+
+ {value} +
+
+ ); +} + +function OutcomeRow({ + item, + selected, + agents, + channelNames, + onSelect, +}: { + item: LoadedOutcome; + selected: boolean; + agents: Map; + channelNames: Map; + onSelect: () => void; +}) { + const evidence = item.outcome.evidence[0]; + const agent = evidence ? agents.get(evidence.agentId) : undefined; + const state = item.details?.state; + return ( + + ); +} + +function OutcomeState({ + state, + loading, +}: { + state: string | undefined; + loading?: boolean; +}) { + const merged = state === "Merged"; + return ( + + {loading ? "checking" : state || "unavailable"} + + ); +} + +function OutcomeInspector({ + item, + agents, + channelNames, + onSelectAgent, +}: { + item: LoadedOutcome; + agents: Map; + channelNames: Map; + onSelectAgent: (agentId: string) => void; +}) { + const facts = new Map(item.details?.facts ?? []); + const uniqueAgents = [ + ...new Set(item.outcome.evidence.map((evidence) => evidence.agentId)), + ]; + return ( + + ); +} + +function InspectorFact({ + label, + value, +}: { + label: string; + value: string | number; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function AgentCard({ + row, + selected, + session, + onSelect, +}: { + row: AgentDashboardRow; + selected: boolean; + session: RelaySession; + onSelect: () => void; +}) { + return ( + + ); +} + +const VIEW_WIDTH = 800; +const VIEW_HEIGHT = 500; + +function AgentNetwork({ + focus, + session, + onSelectAgent, +}: { + focus: AgentFocusNetwork; + session: RelaySession; + onSelectAgent: (agentId: string) => void; +}) { + const channelPositions = radialPositions( + focus.channels.length, + 152, + VIEW_WIDTH / 2, + VIEW_HEIGHT / 2, + -Math.PI / 2, + ); + const collaboratorPositions = radialPositions( + focus.collaborators.length, + 229, + VIEW_WIDTH / 2, + VIEW_HEIGHT / 2, + -Math.PI / 2 + Math.PI / Math.max(focus.collaborators.length, 1), + ); + const channelPosition = new Map( + focus.channels.map(({ channel }, index) => [ + channel.id, + channelPositions[index], + ]), + ); + + return ( +
+
+ + {focus.selected.agent.name} is connected to {focus.channels.length}{" "} + displayed channels and {focus.collaborators.length} displayed + collaborators. + +
+ + +
+
+ +
+ + {focus.selected.agent.name} + +
+ + {focus.channels.map((relationship, index) => { + const position = channelPositions[index]; + if (!position) return null; + return ( +
+ + #{relationship.channel.name} + + + {relationship.edge.membership === "current" + ? "current" + : relationship.edge.membership === "past" + ? "past" + : "membership unknown"} + {relationship.edge.observedMessageCount > 0 + ? ` · ${relationship.edge.observedMessageCount}` + : ""} + +
+ ); + })} + + {focus.collaborators.map((collaborator, index) => { + const position = collaboratorPositions[index]; + if (!position) return null; + return ( + + ); + })} +
+ {(focus.channelsLimited || focus.collaboratorsLimited) && ( +

+ Showing the strongest relationships for a readable map. Full channel + detail is below. +

+ )} +
+ ); +} + +function AgentDetails({ focus }: { focus: AgentFocusNetwork }) { + const row = focus.selected; + return ( +
+ +
+
+

Channel activity

+ + {row.relationships.length} total + +
+
    + {row.relationships.map(({ channel, edge }) => ( +
  • + + + #{channel.name} + + + {channel.kind ?? "channel"} ·{" "} + {membershipLabel(edge.membership)} + + + + {edge.observedMessageCount > 0 ? ( + <> + + + + {edge.lastObservedAt + ? formatRelativeTime(edge.lastObservedAt) + : ""} + + + ) : ( + "No sampled messages" + )} + +
  • + ))} +
+
+
+ ); +} + +function Metric({ value, label }: { value: string | number; label: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function AgentAvatar({ + row, + session, + className, +}: { + row: AgentDashboardRow; + session: RelaySession; + className: string; +}) { + const source = avatarSource(row.agent.avatar); + const picture = source?.startsWith("data:") + ? source + : source + ? session.media(source) + : undefined; + return ; +} + +function PageState({ + library, + channels, + activity, + hasAgents, + hasRelationships, + retry, +}: { + library: AgentLibrary & { status: string; error?: string }; + channels: ReturnType; + activity: ActivityState; + hasAgents: boolean; + hasRelationships: boolean; + retry: () => void; +}) { + if (library.status === "unavailable") + return ( + + ); + const error = library.error ?? channels.error ?? activity.error; + if (error && !hasRelationships) + return ( +
+

Couldn’t update the agent dashboard. {error}

+ +
+ ); + if (library.status === "ready" && !hasAgents) + return ( + + ); + if ( + !hasRelationships && + library.status === "ready" && + channels.status === "ready" + ) + return ( + + ); + return null; +} + +function EmptyState({ text }: { text: string }) { + return ( +
+
+ ); +} + +function Footnote({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +function radialPositions( + count: number, + radius: number, + centerX: number, + centerY: number, + start: number, +) { + if (!count) return []; + return Array.from({ length: count }, (_, index) => { + const angle = start + (index / count) * Math.PI * 2; + return { + x: centerX + Math.cos(angle) * radius, + y: centerY + Math.sin(angle) * radius, + }; + }); +} + +function nodeStyle(position: { x: number; y: number }): CSSProperties { + return { + left: `${(position.x / VIEW_WIDTH) * 100}%`, + top: `${(position.y / VIEW_HEIGHT) * 100}%`, + }; +} + +function membershipLabel(membership: "current" | "past" | "unknown") { + if (membership === "current") return "member now"; + if (membership === "past") return "past activity"; + return "membership unknown"; +} + +function formatRelativeTime(timestamp: number) { + const elapsedDays = Math.max( + 0, + Math.floor((Date.now() - timestamp * 1000) / 86_400_000), + ); + if (elapsedDays === 0) return "today"; + if (elapsedDays === 1) return "1d ago"; + if (elapsedDays < 30) return `${elapsedDays}d ago`; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: + new Date(timestamp * 1000).getFullYear() === new Date().getFullYear() + ? undefined + : "numeric", + }).format(new Date(timestamp * 1000)); +} diff --git a/src/bundled/agent-channels/dashboard.test.ts b/src/bundled/agent-channels/dashboard.test.ts new file mode 100644 index 00000000..76558ccf --- /dev/null +++ b/src/bundled/agent-channels/dashboard.test.ts @@ -0,0 +1,95 @@ +import { expect, it } from "vitest"; +import { buildAgentDashboard, buildAgentFocusNetwork } from "./dashboard"; +import type { AgentChannelGraph } from "./relationships"; + +const graph: AgentChannelGraph = { + agents: [ + { id: "rizz", name: "Rizz", identityPubkeys: ["r"] }, + { id: "fizz", name: "Fizz", identityPubkeys: ["f"] }, + { id: "honey", name: "Honey", identityPubkeys: ["h"] }, + ], + channels: [ + { id: "map", name: "Map", kind: "stream", archived: false }, + { id: "design", name: "Design", kind: "forum", archived: false }, + { id: "past", name: "Past", kind: "stream", archived: false }, + ], + edges: [ + { + agentId: "rizz", + channelId: "map", + membership: "current", + firstObservedAt: 10, + lastObservedAt: 20, + observedMessageCount: 4, + }, + { + agentId: "rizz", + channelId: "design", + membership: "current", + observedMessageCount: 0, + }, + { + agentId: "rizz", + channelId: "past", + membership: "past", + firstObservedAt: 2, + lastObservedAt: 3, + observedMessageCount: 2, + }, + { + agentId: "fizz", + channelId: "design", + membership: "current", + firstObservedAt: 12, + lastObservedAt: 18, + observedMessageCount: 3, + }, + { + agentId: "honey", + channelId: "past", + membership: "unknown", + firstObservedAt: 1, + lastObservedAt: 1, + observedMessageCount: 1, + }, + ], + edgesLimited: false, +}; + +it("builds agent-first totals and orders current work before history", () => { + const dashboard = buildAgentDashboard(graph); + expect(dashboard.map(({ agent }) => agent.name)).toEqual([ + "Rizz", + "Fizz", + "Honey", + ]); + expect(dashboard[0]).toMatchObject({ + currentChannelCount: 2, + pastChannelCount: 1, + unknownChannelCount: 0, + observedMessageCount: 6, + firstObservedAt: 2, + lastObservedAt: 20, + }); + expect(dashboard[0]?.relationships.map(({ channel }) => channel.id)).toEqual([ + "map", + "design", + "past", + ]); +}); + +it("focuses one agent and finds collaborators through displayed channels", () => { + const dashboard = buildAgentDashboard(graph); + const focus = buildAgentFocusNetwork(dashboard, "rizz", 2, 1); + expect(focus?.channels.map(({ channel }) => channel.id)).toEqual([ + "map", + "design", + ]); + expect(focus?.collaborators).toHaveLength(1); + expect(focus?.collaborators[0]).toMatchObject({ + row: { agent: { id: "fizz" } }, + channelIds: ["design"], + }); + expect(focus?.channelsLimited).toBe(true); + expect(focus?.collaboratorsLimited).toBe(false); +}); diff --git a/src/bundled/agent-channels/dashboard.ts b/src/bundled/agent-channels/dashboard.ts new file mode 100644 index 00000000..ceb0b0aa --- /dev/null +++ b/src/bundled/agent-channels/dashboard.ts @@ -0,0 +1,148 @@ +import type { + AgentChannelEdge, + AgentChannelGraph, + AgentNode, + ChannelNode, +} from "./relationships"; + +export type AgentChannelRelationship = Readonly<{ + channel: ChannelNode; + edge: AgentChannelEdge; +}>; + +export type AgentDashboardRow = Readonly<{ + agent: AgentNode; + relationships: readonly AgentChannelRelationship[]; + currentChannelCount: number; + pastChannelCount: number; + unknownChannelCount: number; + observedMessageCount: number; + firstObservedAt?: number; + lastObservedAt?: number; +}>; + +export type AgentFocusNetwork = Readonly<{ + selected: AgentDashboardRow; + channels: readonly AgentChannelRelationship[]; + collaborators: readonly Readonly<{ + row: AgentDashboardRow; + channelIds: readonly string[]; + }>[]; + channelsLimited: boolean; + collaboratorsLimited: boolean; +}>; + +export const FOCUS_CHANNEL_LIMIT = 6; +export const FOCUS_COLLABORATOR_LIMIT = 8; + +export function buildAgentDashboard( + graph: AgentChannelGraph, +): readonly AgentDashboardRow[] { + const channels = new Map( + graph.channels.map((channel) => [channel.id, channel]), + ); + const edges = new Map(); + for (const edge of graph.edges) { + const grouped = edges.get(edge.agentId); + if (grouped) grouped.push(edge); + else edges.set(edge.agentId, [edge]); + } + + return Object.freeze( + graph.agents + .map((agent) => { + const relationships = (edges.get(agent.id) ?? []) + .flatMap((edge) => { + const channel = channels.get(edge.channelId); + return channel ? [{ channel, edge }] : []; + }) + .sort(compareRelationships); + const observed = relationships.filter( + ({ edge }) => edge.lastObservedAt !== undefined, + ); + const firstObservedAt = observed.length + ? Math.min(...observed.map(({ edge }) => edge.firstObservedAt ?? 0)) + : undefined; + const lastObservedAt = observed.length + ? Math.max(...observed.map(({ edge }) => edge.lastObservedAt ?? 0)) + : undefined; + return Object.freeze({ + agent, + relationships: Object.freeze(relationships), + currentChannelCount: relationships.filter( + ({ edge }) => edge.membership === "current", + ).length, + pastChannelCount: relationships.filter( + ({ edge }) => edge.membership === "past", + ).length, + unknownChannelCount: relationships.filter( + ({ edge }) => edge.membership === "unknown", + ).length, + observedMessageCount: relationships.reduce( + (total, { edge }) => total + edge.observedMessageCount, + 0, + ), + ...(firstObservedAt !== undefined ? { firstObservedAt } : {}), + ...(lastObservedAt !== undefined ? { lastObservedAt } : {}), + }); + }) + .sort( + (a, b) => + b.currentChannelCount - a.currentChannelCount || + (b.lastObservedAt ?? 0) - (a.lastObservedAt ?? 0) || + b.observedMessageCount - a.observedMessageCount || + a.agent.name.localeCompare(b.agent.name, "en"), + ), + ); +} + +export function buildAgentFocusNetwork( + dashboard: readonly AgentDashboardRow[], + agentId: string, + channelLimit = FOCUS_CHANNEL_LIMIT, + collaboratorLimit = FOCUS_COLLABORATOR_LIMIT, +): AgentFocusNetwork | undefined { + const selected = dashboard.find((row) => row.agent.id === agentId); + if (!selected) return undefined; + const channels = selected.relationships.slice(0, channelLimit); + const channelIds = new Set(channels.map(({ channel }) => channel.id)); + const collaborators = dashboard + .filter((row) => row.agent.id !== agentId) + .flatMap((row) => { + const shared = row.relationships + .filter(({ channel }) => channelIds.has(channel.id)) + .map(({ channel }) => channel.id); + return shared.length ? [{ row, channelIds: Object.freeze(shared) }] : []; + }) + .sort( + (a, b) => + b.channelIds.length - a.channelIds.length || + (b.row.lastObservedAt ?? 0) - (a.row.lastObservedAt ?? 0) || + a.row.agent.name.localeCompare(b.row.agent.name, "en"), + ); + return Object.freeze({ + selected, + channels: Object.freeze(channels), + collaborators: Object.freeze(collaborators.slice(0, collaboratorLimit)), + channelsLimited: selected.relationships.length > channels.length, + collaboratorsLimited: collaborators.length > collaboratorLimit, + }); +} + +function compareRelationships( + a: AgentChannelRelationship, + b: AgentChannelRelationship, +) { + return ( + membershipRank(a.edge.membership) - membershipRank(b.edge.membership) || + (b.edge.lastObservedAt ?? 0) - (a.edge.lastObservedAt ?? 0) || + b.edge.observedMessageCount - a.edge.observedMessageCount || + a.channel.name.localeCompare(b.channel.name, "en") + ); +} + +function membershipRank(membership: AgentChannelEdge["membership"]) { + if (membership === "current") return 0; + if (membership === "unknown") return 1; + return 2; +} diff --git a/src/bundled/agent-channels/index.tsx b/src/bundled/agent-channels/index.tsx new file mode 100644 index 00000000..4ec4412f --- /dev/null +++ b/src/bundled/agent-channels/index.tsx @@ -0,0 +1,14 @@ +import type { PluginModule } from "../../plugins/api"; +import { AgentChannelsPage } from "./AgentChannelsPage"; + +export const inject = ["pages", "relay", "objects"]; +export const apply: PluginModule["apply"] = (ctx) => { + const relay = ctx.relay; + const objects = ctx.objects; + ctx.pages.register({ + id: "agent-channels", + title: "Agent dashboard", + layout: "workspace", + component: () => , + }); +}; diff --git a/src/bundled/agent-channels/manifest.json b/src/bundled/agent-channels/manifest.json new file mode 100644 index 00000000..81938676 --- /dev/null +++ b/src/bundled/agent-channels/manifest.json @@ -0,0 +1 @@ +{ "id": "buzz.agent-channels", "name": "Agent dashboard", "apiVersion": 1 } diff --git a/src/bundled/agent-channels/outcomes.test.ts b/src/bundled/agent-channels/outcomes.test.ts new file mode 100644 index 00000000..9ee38ae7 --- /dev/null +++ b/src/bundled/agent-channels/outcomes.test.ts @@ -0,0 +1,103 @@ +import { expect, it } from "vitest"; +import type { Objects } from "../../features/objects/service"; +import type { VisibleEvent } from "../../features/relay/projection"; +import { extractAgentOutcomes } from "./outcomes"; + +const objects = { + resolve(target: string) { + const match = + /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/([1-9]\d*)$/.exec(target); + return match + ? { + provider: { key: "github" }, + reference: { + provider: "github", + key: `${match[1]}:pull:#${match[2]}`, + kind: "pull", + url: target, + label: `#${match[2]}`, + group: match[1], + }, + } + : undefined; + }, +} as unknown as Pick; +const event = ( + id: string, + pubkey: string, + channel: string, + content: string, + created_at: number, +): VisibleEvent => ({ + id: id.padEnd(64, "0"), + pubkey, + kind: 9, + content, + created_at, + tags: [["h", channel]], +}); +const agents = [ + { id: "rizz", name: "Rizz", identityPubkeys: ["r"] }, + { id: "fizz", name: "Fizz", identityPubkeys: ["f"] }, +]; +const channels = [ + { id: "map", name: "Map", archived: false }, + { id: "other", name: "Other", archived: false }, +]; + +it("deduplicates directly agent-shared pull requests while preserving signed evidence", () => { + const outcomes = extractAgentOutcomes( + [ + event( + "a", + "r", + "map", + "Shipped https://github.com/block/buzz/pull/8", + 20, + ), + event( + "b", + "f", + "other", + "Review (https://github.com/block/buzz/pull/8).", + 30, + ), + event( + "c", + "r", + "map", + "Issue https://github.com/block/buzz/issues/8", + 40, + ), + event("d", "human", "map", "https://github.com/block/buzz/pull/9", 50), + ], + agents, + channels, + objects, + ); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ + reference: { label: "#8", group: "block/buzz" }, + evidence: [ + { agentId: "fizz", channelId: "other", sharedAt: 30 }, + { agentId: "rizz", channelId: "map", sharedAt: 20 }, + ], + }); +}); + +it("ignores failed and unauthorized-channel evidence", () => { + expect( + extractAgentOutcomes( + [ + { + ...event("a", "r", "map", "https://github.com/block/buzz/pull/1", 1), + delivery: "failed", + }, + event("b", "r", "hidden", "https://github.com/block/buzz/pull/2", 2), + ], + agents, + channels, + objects, + ), + ).toEqual([]); +}); diff --git a/src/bundled/agent-channels/outcomes.ts b/src/bundled/agent-channels/outcomes.ts new file mode 100644 index 00000000..33d33663 --- /dev/null +++ b/src/bundled/agent-channels/outcomes.ts @@ -0,0 +1,95 @@ +import type { + ExternalObjectReference, + Objects, +} from "../../features/objects/service"; +import type { VisibleEvent } from "../../features/relay/projection"; +import type { AgentNode, ChannelNode } from "./relationships"; +import { isObservedActivityEvent } from "./relationships"; + +export type OutcomeEvidence = Readonly<{ + agentId: string; + channelId: string; + messageEventId: string; + sharedAt: number; +}>; +export type AgentOutcomeReference = Readonly<{ + key: string; + reference: ExternalObjectReference; + evidence: readonly OutcomeEvidence[]; +}>; + +const URL = /https:\/\/[^\s<>"']+/g; + +export function extractAgentOutcomes( + activity: readonly VisibleEvent[], + agents: readonly AgentNode[], + channels: readonly ChannelNode[], + objects: Pick, +): readonly AgentOutcomeReference[] { + const agentByPubkey = new Map( + agents.flatMap((agent) => + agent.identityPubkeys.map((pubkey) => [pubkey, agent.id] as const), + ), + ); + const channelIds = new Set(channels.map((channel) => channel.id)); + const outcomes = new Map< + string, + { reference: ExternalObjectReference; evidence: OutcomeEvidence[] } + >(); + for (const event of activity) { + if (event.kind !== 9 || !isObservedActivityEvent(event)) continue; + const agentId = agentByPubkey.get(event.pubkey); + const channelId = event.tags.find(([name]) => name === "h")?.[1]; + if (!agentId || !channelId || !channelIds.has(channelId)) continue; + for (const candidate of event.content.match(URL) ?? []) { + const target = trimUrl(candidate); + const resolved = objects.resolve(target); + if (resolved?.reference.kind !== "pull") continue; + const key = `${resolved.provider.key}:${resolved.reference.key}`; + const evidence = Object.freeze({ + agentId, + channelId, + messageEventId: event.id, + sharedAt: event.created_at, + }); + const existing = outcomes.get(key); + if (existing) { + if ( + !existing.evidence.some( + (item) => item.messageEventId === evidence.messageEventId, + ) + ) + existing.evidence.push(evidence); + } else + outcomes.set(key, { + reference: resolved.reference, + evidence: [evidence], + }); + } + } + return Object.freeze( + [...outcomes.entries()] + .map(([key, outcome]) => + Object.freeze({ + key, + reference: outcome.reference, + evidence: Object.freeze( + outcome.evidence.sort( + (a, b) => + b.sharedAt - a.sharedAt || + a.messageEventId.localeCompare(b.messageEventId), + ), + ), + }), + ) + .sort( + (a, b) => + (b.evidence[0]?.sharedAt ?? 0) - (a.evidence[0]?.sharedAt ?? 0) || + a.key.localeCompare(b.key), + ), + ); +} + +function trimUrl(value: string) { + return value.replace(/[),.;:!?\]}]+$/g, ""); +} diff --git a/src/bundled/agent-channels/relationships.test.ts b/src/bundled/agent-channels/relationships.test.ts new file mode 100644 index 00000000..b1e0bd9e --- /dev/null +++ b/src/bundled/agent-channels/relationships.test.ts @@ -0,0 +1,329 @@ +import { expect, it, vi } from "vitest"; +import type { AgentLibrary } from "../../features/agents/library"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import { keypair, message } from "../../features/relay/testing"; +import { + agentNodes, + buildAgentChannelGraph, + GRAPH_EDGE_LIMIT, + readAgentActivity, + type AgentNode, +} from "./relationships"; + +const rizz = keypair(); +const fizz = keypair(); +const library: AgentLibrary = { + definitions: [ + { id: "rizz", name: "Rizz" }, + { id: "fizz", name: "Fizz" }, + { id: "empty", name: "No identity" }, + ], + identities: [ + { pubkey: rizz.pubkey, name: "Rizz", definitionId: "rizz" }, + { pubkey: fizz.pubkey, name: "Fizz", definitionId: "fizz" }, + ], +}; +const channels: ChannelSummary[] = [ + { id: "current", name: "Current", members: [rizz.pubkey] }, + { id: "past", name: "Past", members: [] }, + { id: "unknown", name: "Unknown" }, +]; + +it("keeps exact active identities and omits definitions without a usable identity", () => { + expect(agentNodes(library, (key) => key === fizz.pubkey)).toEqual([ + { + id: "definition:rizz", + name: "Rizz", + identityPubkeys: [rizz.pubkey], + }, + ]); +}); + +it("projects current membership and verified activity into graph-ready edges", () => { + const agents = agentNodes(library, () => false); + const graph = buildAgentChannelGraph(channels, agents, [ + message(rizz, "past", "older", 10), + message(rizz, "past", "newer", 20), + { ...message(rizz, "past", "pending", 30), delivery: "sending" }, + message(fizz, "unknown", "hello", 15), + message(fizz, "not-authorized", "hidden", 30), + ]); + expect(graph.edgesLimited).toBe(false); + expect(graph.edges).toEqual([ + { + agentId: "definition:rizz", + channelId: "current", + membership: "current", + observedMessageCount: 0, + }, + { + agentId: "definition:rizz", + channelId: "past", + membership: "past", + firstObservedAt: 10, + lastObservedAt: 20, + observedMessageCount: 2, + }, + { + agentId: "definition:fizz", + channelId: "unknown", + membership: "unknown", + firstObservedAt: 15, + lastObservedAt: 15, + observedMessageCount: 1, + }, + ]); +}); + +it("downgrades roster membership when coverage is partial", () => { + const [agent] = agentNodes(library, () => false); + expect( + buildAgentChannelGraph(channels, agent ? [agent] : [], [], false).edges, + ).toEqual([ + { + agentId: "definition:rizz", + channelId: "current", + membership: "unknown", + observedMessageCount: 0, + }, + ]); +}); + +it("caps the relationship projection while preserving current edges first", () => { + const crowdedAgent: AgentNode = { + id: "crowded", + name: "Crowded", + identityPubkeys: [rizz.pubkey], + }; + const crowdedChannels = Array.from( + { length: GRAPH_EDGE_LIMIT + 1 }, + (_, index): ChannelSummary => ({ + id: `channel-${index}`, + name: `Channel ${index}`, + members: index === GRAPH_EDGE_LIMIT ? [rizz.pubkey] : [], + }), + ); + const activity = crowdedChannels + .slice(0, GRAPH_EDGE_LIMIT) + .map((channel, index) => ({ + id: String(index).padStart(64, "0"), + pubkey: rizz.pubkey, + created_at: index, + kind: 9, + content: "observed", + tags: [["h", channel.id]], + })); + const graph = buildAgentChannelGraph( + crowdedChannels, + [crowdedAgent], + activity, + ); + expect(graph.edgesLimited).toBe(true); + expect(graph.edges).toHaveLength(GRAPH_EDGE_LIMIT); + expect(graph.edges[0]).toMatchObject({ + channelId: `channel-${GRAPH_EDGE_LIMIT}`, + membership: "current", + }); +}); + +it("reads activity through the shared session with exact authors and cancellation", async () => { + const events = [message(rizz, "current", "hello", 20)]; + const read = vi.fn().mockResolvedValue(events); + const controller = new AbortController(); + const result = await readAgentActivity( + { read } as never, + [rizz.pubkey, rizz.pubkey], + ["current", "current"], + controller.signal, + ); + expect(result).toEqual(events); + expect(read).toHaveBeenCalledWith( + [ + { + kinds: [9], + authors: [rizz.pubkey], + "#h": ["current"], + limit: 500, + }, + ], + { signal: controller.signal, priority: "background" }, + ); +}); + +it("keeps explicit channel filters within the relay's 128-channel limit", async () => { + const channelIds = Array.from( + { length: 129 }, + (_, index) => `channel-${index}`, + ); + const read = vi.fn().mockResolvedValue([]); + + await readAgentActivity( + { read } as never, + [rizz.pubkey], + channelIds, + new AbortController().signal, + ); + + expect(read).toHaveBeenCalledTimes(2); + expect(read.mock.calls.map((call) => call[0][0]["#h"])).toEqual([ + channelIds.slice(0, 128), + channelIds.slice(128), + ]); + expect( + Math.max(...read.mock.calls.map((call) => call[0][0]["#h"].length)), + ).toBe(128); +}); + +it("uses eligible relay observations rather than pending outbox rows for pagination", async () => { + const remote = Array.from({ length: 500 }, (_, index) => ({ + ...message(rizz, "current", `remote-${index}`, 1_000 - index), + id: String(index).padStart(64, "0"), + ...(index === 0 + ? { delivery: "accepted" as const } + : index === 1 + ? { delivery: "seen" as const } + : {}), + })); + const pending = { + ...message(rizz, "current", "pending", 750), + id: String(250).padStart(64, "0"), + delivery: "sending" as const, + }; + const read = vi + .fn() + .mockResolvedValueOnce([ + pending, + ...remote.filter((event) => event.id !== pending.id), + ]) + .mockResolvedValue([]); + await readAgentActivity( + { read } as never, + [rizz.pubkey], + ["current"], + new AbortController().signal, + ); + expect(read).toHaveBeenCalledTimes(2); + expect(read.mock.calls[1]?.[0]).toEqual([ + expect.objectContaining({ + until: 501, + before_id: String(499).padStart(64, "0"), + }), + ]); +}); + +it("keeps accepted local observations out of the relay pagination cursor", async () => { + const remote = Array.from({ length: 500 }, (_, index) => ({ + ...message(rizz, "current", `remote-${index}`, 1_000 - index), + id: String(index).padStart(64, "0"), + })); + const acceptedLocal = { + ...message(rizz, "current", "accepted-local", 1), + id: "f".repeat(64), + delivery: "accepted" as const, + }; + const read = vi + .fn() + .mockResolvedValueOnce([acceptedLocal, ...remote]) + .mockResolvedValue([]); + + const result = await readAgentActivity( + { read } as never, + [rizz.pubkey], + ["current"], + new AbortController().signal, + ); + + expect(result).toContainEqual(acceptedLocal); + expect(read).toHaveBeenCalledTimes(2); + expect(read.mock.calls[1]?.[0]).toEqual([ + expect.objectContaining({ + until: 501, + before_id: String(499).padStart(64, "0"), + }), + ]); +}); + +it("shares the aggregate ceiling fairly across author batches", async () => { + const authors = Array.from({ length: 301 }, (_, index) => `author-${index}`); + const calls = new Map(); + const read = vi.fn( + async ([filter]: [{ authors: string[]; limit: number }]) => { + const batch = filter.authors[0] ?? ""; + const page = calls.get(batch) ?? 0; + calls.set(batch, page + 1); + return Array.from({ length: filter.limit }, (_, index) => { + const sequence = page * filter.limit + index; + return { + id: `${batch === authors[300] ? "b" : "a"}${String(sequence).padStart(63, "0")}`, + pubkey: batch, + created_at: (batch === authors[300] ? 3_000 : 2_000) - sequence, + kind: 9, + content: "", + tags: [["h", "current"]], + }; + }); + }, + ); + const result = await readAgentActivity( + { read } as never, + authors, + ["current"], + new AbortController().signal, + ); + expect(result).toHaveLength(2_000); + expect(new Set(read.mock.calls.map((call) => call[0][0].limit))).toEqual( + new Set([500]), + ); + expect(result[0]?.pubkey).toBe(authors[300]); + expect(read).toHaveBeenCalledTimes(4); +}); + +it("removes ineligible outbox rows before applying the aggregate ceiling", async () => { + const authors = Array.from({ length: 301 }, (_, index) => `author-${index}`); + const read = vi.fn(async ([filter]: [{ authors: string[] }]) => { + const pubkey = filter.authors[0] ?? ""; + if (pubkey === authors[300]) + return [ + { + id: "newest-eligible", + pubkey, + created_at: 4_000, + kind: 9, + content: "", + tags: [["h", "current"]], + delivery: "seen" as const, + }, + ]; + return [ + ...Array.from({ length: 2_000 }, (_, index) => ({ + id: `pending-${index}`, + pubkey, + created_at: 10_000 - index, + kind: 9, + content: "", + tags: [["h", "current"]], + delivery: "sending" as const, + })), + { + id: "older-eligible", + pubkey, + created_at: 1_000, + kind: 9, + content: "", + tags: [["h", "current"]], + }, + ]; + }); + + const result = await readAgentActivity( + { read } as never, + authors, + ["current"], + new AbortController().signal, + ); + + expect(result.map((event) => event.id)).toEqual([ + "newest-eligible", + "older-eligible", + ]); +}); diff --git a/src/bundled/agent-channels/relationships.ts b/src/bundled/agent-channels/relationships.ts new file mode 100644 index 00000000..4d2a0f92 --- /dev/null +++ b/src/bundled/agent-channels/relationships.ts @@ -0,0 +1,296 @@ +import type { AgentLibrary } from "../../features/agents/library"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import type { VisibleEvent } from "../../features/relay/projection"; +import type { RelaySession } from "../../features/relay/session"; + +export type AgentNode = Readonly<{ + id: string; + name: string; + avatar?: string; + identityPubkeys: readonly string[]; +}>; +export type ChannelNode = Readonly<{ + id: string; + name: string; + kind?: ChannelSummary["channelType"]; + archived: boolean; +}>; +export type AgentChannelEdge = Readonly<{ + agentId: string; + channelId: string; + membership: "current" | "past" | "unknown"; + firstObservedAt?: number; + lastObservedAt?: number; + observedMessageCount: number; +}>; +export type AgentChannelGraph = Readonly<{ + agents: readonly AgentNode[]; + channels: readonly ChannelNode[]; + edges: readonly AgentChannelEdge[]; + edgesLimited: boolean; +}>; + +const HISTORY_PAGE_SIZE = 500; +export const HISTORY_EVENT_LIMIT = 2_000; +export const GRAPH_EDGE_LIMIT = 10_000; +const AUTHOR_BATCH_SIZE = 300; +// Buzz rejects filters with more than 128 explicit channels. Keep this bound +// local to the activity projection rather than widening the shared reader API. +const CHANNEL_BATCH_SIZE = 128; + +export function agentNodes( + library: AgentLibrary, + archived: (pubkey: string) => boolean, +): readonly AgentNode[] { + const visible = library.identities.filter( + (identity) => !archived(identity.pubkey), + ); + const definitions = new Map( + library.definitions.map((definition) => [definition.id, definition]), + ); + const grouped = library.definitions.map((definition) => ({ + id: `definition:${definition.id}`, + name: definition.name, + ...(definition.avatar ? { avatar: definition.avatar } : {}), + identityPubkeys: visible + .filter((identity) => identity.definitionId === definition.id) + .map((identity) => identity.pubkey), + })); + const loose = visible + .filter( + (identity) => + !identity.definitionId || !definitions.has(identity.definitionId), + ) + .map((identity) => ({ + id: `identity:${identity.pubkey}`, + name: identity.name, + ...(identity.avatar ? { avatar: identity.avatar } : {}), + identityPubkeys: [identity.pubkey], + })); + return Object.freeze( + [...grouped, ...loose] + .filter((agent) => agent.identityPubkeys.length > 0) + .map((agent) => Object.freeze(agent)), + ); +} + +export function isObservedActivityEvent(event: VisibleEvent): boolean { + return ( + event.delivery === undefined || + event.delivery === "accepted" || + event.delivery === "seen" + ); +} + +export function buildAgentChannelGraph( + channels: readonly ChannelSummary[], + agents: readonly AgentNode[], + activity: readonly VisibleEvent[], + membershipComplete = true, +): AgentChannelGraph { + const channelNodes = channels.map((channel) => + Object.freeze({ + id: channel.id, + name: channel.name, + kind: channel.channelType, + archived: !!channel.archived, + }), + ); + const channelsById = new Map( + channels.map((channel) => [channel.id, channel]), + ); + const agentsById = new Map(agents.map((agent) => [agent.id, agent])); + const agentByIdentity = new Map( + agents.flatMap((agent) => + agent.identityPubkeys.map((pubkey) => [pubkey, agent] as const), + ), + ); + const observed = new Map< + string, + { + agentId: string; + channelId: string; + first: number; + last: number; + count: number; + } + >(); + for (const event of activity) { + if (event.kind !== 9 || !isObservedActivityEvent(event)) continue; + const agent = agentByIdentity.get(event.pubkey); + const channelId = event.tags.find(([name]) => name === "h")?.[1]; + if (!agent || !channelId || !channelsById.has(channelId)) continue; + const key = `${agent.id}\u0000${channelId}`; + const edge = observed.get(key); + if (edge) { + edge.first = Math.min(edge.first, event.created_at); + edge.last = Math.max(edge.last, event.created_at); + edge.count++; + } else { + observed.set(key, { + agentId: agent.id, + channelId, + first: event.created_at, + last: event.created_at, + count: 1, + }); + } + } + const memberChannelsByIdentity = new Map(); + for (const channel of channels) + for (const pubkey of channel.members ?? []) { + const memberChannels = memberChannelsByIdentity.get(pubkey); + if (memberChannels) memberChannels.push(channel.id); + else memberChannelsByIdentity.set(pubkey, [channel.id]); + } + const edgeKeys = new Set(observed.keys()); + const currentKeys = new Set(); + for (const agent of agents) + for (const pubkey of agent.identityPubkeys) + for (const channelId of memberChannelsByIdentity.get(pubkey) ?? []) { + const key = `${agent.id}\u0000${channelId}`; + edgeKeys.add(key); + currentKeys.add(key); + } + const sortedEdgeKeys = [...edgeKeys].sort((a, b) => { + const aObserved = observed.get(a); + const bObserved = observed.get(b); + return ( + Number(currentKeys.has(b)) - Number(currentKeys.has(a)) || + (bObserved?.last ?? 0) - (aObserved?.last ?? 0) || + a.localeCompare(b) + ); + }); + const edges: AgentChannelEdge[] = []; + for (const key of sortedEdgeKeys) { + if (edges.length >= GRAPH_EDGE_LIMIT) break; + const separator = key.indexOf("\u0000"); + const agentId = key.slice(0, separator); + const channelId = key.slice(separator + 1); + const agent = agentsById.get(agentId); + const channel = channelsById.get(channelId); + if (!agent || !channel) continue; + const activityEdge = observed.get(key); + const membership = currentKeys.has(key) + ? membershipComplete + ? "current" + : "unknown" + : channel.members && membershipComplete + ? "past" + : "unknown"; + edges.push( + Object.freeze({ + agentId, + channelId, + membership, + ...(activityEdge + ? { + firstObservedAt: activityEdge.first, + lastObservedAt: activityEdge.last, + } + : {}), + observedMessageCount: activityEdge?.count ?? 0, + }), + ); + } + return Object.freeze({ + agents, + channels: Object.freeze(channelNodes), + edges: Object.freeze(edges), + edgesLimited: sortedEdgeKeys.length > edges.length, + }); +} + +/** Reads a bounded recent activity sample through the shared verified session. + * The response has no completeness bound, so callers must describe it as observed activity. */ +export async function readAgentActivity( + session: RelaySession, + pubkeys: readonly string[], + channelIds: readonly string[], + signal: AbortSignal, +): Promise { + const authorBatches = batches([...new Set(pubkeys)], AUTHOR_BATCH_SIZE); + const channelBatches = batches([...new Set(channelIds)], CHANNEL_BATCH_SIZE); + const queries = authorBatches.flatMap((authors) => + channelBatches.map((channels) => ({ authors, channels })), + ); + const events = new Map(); + let remainingBudget = HISTORY_EVENT_LIMIT; + for (const [index, query] of queries.entries()) { + const batchBudget = Math.floor(remainingBudget / (queries.length - index)); + for (const event of await readActivityBatch( + session, + query.authors, + query.channels, + signal, + batchBudget, + )) + events.set(event.id, event); + remainingBudget -= batchBudget; + } + return Object.freeze( + [...events.values()] + .filter(isObservedActivityEvent) + .sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id)) + .slice(0, HISTORY_EVENT_LIMIT), + ); +} + +async function readActivityBatch( + session: RelaySession, + authors: readonly string[], + channelIds: readonly string[], + signal: AbortSignal, + eventBudget: number, +): Promise { + const events = new Map(); + const relayEvents = new Map(); + let cursor: { createdAt: number; eventId: string } | undefined; + while (relayEvents.size < eventBudget) { + signal.throwIfAborted(); + const requestLimit = Math.min( + HISTORY_PAGE_SIZE, + eventBudget - relayEvents.size, + ); + const page = await session.read( + [ + { + kinds: [9], + authors, + "#h": channelIds, + limit: requestLimit, + ...(cursor + ? { until: cursor.createdAt, before_id: cursor.eventId } + : {}), + }, + ], + { signal, priority: "background" }, + ); + for (const event of page) events.set(event.id, event); + const relayPage = page.filter( + (event) => + isObservedActivityEvent(event) && event.delivery !== "accepted", + ); + for (const event of relayPage) relayEvents.set(event.id, event); + if (page.length < requestLimit || relayEvents.size >= eventBudget) break; + const oldest = [...relayPage].sort( + (a, b) => a.created_at - b.created_at || b.id.localeCompare(a.id), + )[0]; + if (!oldest) break; + const next = { createdAt: oldest.created_at, eventId: oldest.id }; + if ( + cursor && + (next.createdAt > cursor.createdAt || + (next.createdAt === cursor.createdAt && next.eventId <= cursor.eventId)) + ) + break; + cursor = next; + } + return Object.freeze([...events.values()].filter(isObservedActivityEvent)); +} + +function batches(values: readonly T[], size: number): readonly T[][] { + return Array.from({ length: Math.ceil(values.length / size) }, (_, index) => + values.slice(index * size, (index + 1) * size), + ); +} diff --git a/src/bundled/github/index.tsx b/src/bundled/github/index.tsx index 51b55623..26303969 100644 --- a/src/bundled/github/index.tsx +++ b/src/bundled/github/index.tsx @@ -8,11 +8,12 @@ import { } from "lucide-react"; import type { PluginModule } from "../../plugins/api"; import type { PanelProps } from "../../features/panels/service"; +import type { ExternalObjectReference } from "../../features/objects/service"; import { parseGitHubReference, type GitHubReference } from "./references"; import { loadGitHubDetails, type GitHubDetails } from "./data"; import styles from "./GitHub.module.css"; -export const inject = ["panels"]; +export const inject = ["panels", "objects"]; export const apply: PluginModule["apply"] = (ctx) => { ctx.panels.register({ id: "object", @@ -20,7 +21,40 @@ export const apply: PluginModule["apply"] = (ctx) => { matches: (target) => !!parseGitHubReference(target), component: GitHubPanel, }); + ctx.objects.register({ + id: "objects", + title: "GitHub", + identify: githubObjectReference, + async load(reference, signal) { + const github = parseGitHubReference(reference.url); + if (!github) throw new Error("Unsupported GitHub object."); + const details = await loadGitHubDetails(github, signal); + return Object.freeze({ + reference, + title: details.title, + ...(details.body ? { body: details.body } : {}), + ...(details.state ? { state: details.state } : {}), + ...(details.author ? { author: details.author } : {}), + facts: Object.freeze(details.facts), + }); + }, + }); }; + +function githubObjectReference( + target: string, +): ExternalObjectReference | undefined { + const reference = parseGitHubReference(target); + if (!reference) return; + return Object.freeze({ + provider: "github", + key: `${reference.repository}:${reference.kind}:${reference.label}`, + kind: reference.kind, + url: reference.url, + label: reference.label, + group: reference.repository, + }); +} const labels = { repository: "Repository", pull: "Pull request", diff --git a/src/bundled/index.ts b/src/bundled/index.ts index 42a731cd..91f5f701 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -6,6 +6,8 @@ import profilesManifest from "./profiles/manifest.json"; import * as profiles from "./profiles"; import mentionsManifest from "./mentions/manifest.json"; import * as mentions from "./mentions"; +import agentChannelsManifest from "./agent-channels/manifest.json"; +import * as agentChannels from "./agent-channels"; import emojiManifest from "./emoji/manifest.json"; import * as emoji from "./emoji"; import agentsManifest from "./agents/manifest.json"; @@ -31,4 +33,8 @@ export const bundledPlugins: readonly BundledPlugin[] = [ { manifest: { ...bestieManifest, apiVersion: 1 }, module: bestie }, { manifest: { ...projectsManifest, apiVersion: 1 }, module: projects }, { manifest: { ...agentsManifest, apiVersion: 1 }, module: agents }, + { + manifest: { ...agentChannelsManifest, apiVersion: 1 }, + module: agentChannels, + }, ]; diff --git a/src/features/objects/service.test.ts b/src/features/objects/service.test.ts new file mode 100644 index 00000000..9898c1db --- /dev/null +++ b/src/features/objects/service.test.ts @@ -0,0 +1,69 @@ +import { Context } from "@deepseek-ai/cordis"; +import { expect, it } from "vitest"; +import { ObjectsService } from "./service"; + +it("resolves the first healthy active provider and removes disposed registrations", async () => { + const root = new Context(); + let active = true; + const listeners = new Set<() => void>(); + root.provide("pluginStatus", { + isActive: () => active, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }); + const objects = new ObjectsService(root); + const scope = root.extend({ pluginOwner: { id: "test", revision: "one" } }); + const fiber = scope.plugin((ctx) => { + ctx.objects.register({ + id: "broken", + title: "Broken", + identify: () => { + throw new Error("broken"); + }, + load: async () => { + throw new Error("unused"); + }, + }); + ctx.objects.register({ + id: "github", + title: "GitHub", + identify: (target) => + target.includes("github.com") + ? { + provider: "github", + key: "pull:1", + kind: "pull", + url: target, + label: "#1", + } + : undefined, + load: async (reference) => ({ + reference, + title: "A pull request", + facts: [], + }), + }); + }); + await fiber.await(); + expect(objects.resolve("https://github.com/block/buzz/pull/1")).toMatchObject( + { + provider: { id: "github" }, + reference: { label: "#1" }, + }, + ); + await expect( + objects.load( + "https://github.com/block/buzz/pull/1", + new AbortController().signal, + ), + ).resolves.toMatchObject({ title: "A pull request" }); + active = false; + for (const listener of listeners) listener(); + expect( + objects.resolve("https://github.com/block/buzz/pull/1"), + ).toBeUndefined(); + await fiber.dispose(); + await root.fiber.dispose(); +}); diff --git a/src/features/objects/service.ts b/src/features/objects/service.ts new file mode 100644 index 00000000..be7854fe --- /dev/null +++ b/src/features/objects/service.ts @@ -0,0 +1,91 @@ +// FOUNDATION: Typed external-object data contributions, independent of page and panel layout. +import { Service, type Context } from "@deepseek-ai/cordis"; +import { + createContributions, + type Contribution, +} from "../../plugins/contributions"; + +export type ExternalObjectReference = Readonly<{ + provider: string; + key: string; + kind: string; + url: string; + label: string; + group?: string; +}>; +export type ExternalObjectDetails = Readonly<{ + reference: ExternalObjectReference; + title: string; + body?: string; + state?: string; + author?: string; + facts: readonly (readonly [string, string | number])[]; +}>; +export type ObjectProvider = Readonly<{ + id: string; + title: string; + identify(target: string): ExternalObjectReference | undefined; + load( + reference: ExternalObjectReference, + signal: AbortSignal, + ): Promise; +}>; +export type RegisteredObjectProvider = Contribution; +export type ResolvedObject = Readonly<{ + provider: RegisteredObjectProvider; + reference: ExternalObjectReference; +}>; +export type Objects = { + register(provider: ObjectProvider): void; + resolve(target: string): ResolvedObject | undefined; + load( + target: string, + signal: AbortSignal, + ): Promise; + snapshot(): readonly RegisteredObjectProvider[]; + subscribe(listener: () => void): () => void; +}; +declare module "@deepseek-ai/cordis" { + interface Context { + objects: Objects; + } +} + +export class ObjectsService extends Service implements Objects { + private readonly providers; + constructor(ctx: Context) { + super(ctx, "objects"); + this.providers = createContributions(ctx); + } + snapshot = () => this.providers.snapshot(); + subscribe = (listener: () => void) => this.providers.subscribe(listener); + resolve = (target: string) => { + for (const provider of this.providers.snapshot()) { + try { + const reference = provider.identify(target); + if (reference) return Object.freeze({ provider, reference }); + } catch { + // Authored content is not trusted to keep a provider matcher healthy. + } + } + }; + load = async (target: string, signal: AbortSignal) => { + const resolved = this.resolve(target); + return resolved?.provider.load(resolved.reference, signal); + }; + register(provider: ObjectProvider) { + if ( + !provider || + typeof provider.id !== "string" || + !/^[a-z0-9][a-z0-9._-]*$/.test(provider.id) || + typeof provider.title !== "string" || + !provider.title.trim() || + typeof provider.identify !== "function" || + typeof provider.load !== "function" + ) + throw new Error( + "An object provider needs an id, title, identifier, and loader", + ); + this.providers.register(this.ctx, provider); + } +} diff --git a/src/features/panels/service.test.tsx b/src/features/panels/service.test.tsx index d25642d4..9adc4b2e 100644 --- a/src/features/panels/service.test.tsx +++ b/src/features/panels/service.test.tsx @@ -3,6 +3,7 @@ import { Context } from "@deepseek-ai/cordis"; import { PluginRuntime } from "../../plugins/runtime"; import { PagesService } from "../pages/service"; import { PanelsService } from "../panels/service"; +import { ObjectsService } from "../objects/service"; import { ConversationService } from "../conversation/service"; import { provideRelay } from "../relay/service"; import { provideNavigation } from "../navigation/service"; @@ -26,6 +27,7 @@ it("independently unloads panels without removing the page or shared data", asyn ); const pages = new PagesService(root), panels = new PanelsService(root); + new ObjectsService(root); new ConversationService(root); provideNavigation(root); const relay = provideRelay(root); diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index 12e18e9d..6ff6cae1 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -55,6 +55,8 @@ --color-warning: var(--warning); --color-warning-surface: var(--warning-surface); --color-warning-border: var(--warning-border); + --color-success: var(--success); + --color-success-surface: var(--success-surface); --color-overlay: var(--overlay); --color-toggle-track: var(--toggle-track); --color-toggle-thumb: var(--toggle-thumb); diff --git a/tests/browser/agent-dashboard.spec.mjs b/tests/browser/agent-dashboard.spec.mjs new file mode 100644 index 00000000..796d9a80 --- /dev/null +++ b/tests/browser/agent-dashboard.spec.mjs @@ -0,0 +1,91 @@ +import { test, expect } from "@playwright/test"; +import { createServer } from "vite"; +import react from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; + +let server; + +test.beforeAll(async () => { + server = await createServer({ + root: fileURLToPath(new URL("../../", import.meta.url)), + configFile: false, + envFile: false, + plugins: [react()], + logLevel: "error", + server: { host: "127.0.0.1", port: 0, strictPort: false }, + }); + await server.listen(); +}); + +test.afterAll(async () => server.close()); + +const url = () => + `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/agent-channels.html`; + +test("agent dashboard changes point of view and keeps its network usable on narrow screens", async ({ + page, +}) => { + const errors = []; + page.on("pageerror", (error) => errors.push(String(error))); + await page.goto(url()); + const dashboard = page.getByRole("region", { + name: "Agent dashboard", + exact: true, + }); + await expect( + dashboard.getByRole("button", { name: /^Rizz 2 current/ }), + ).toHaveAttribute("aria-pressed", "true"); + await dashboard.getByRole("button", { name: /^Carl 1 current/ }).click(); + await expect( + dashboard.getByRole("button", { name: /^Carl 1 current/ }), + ).toHaveAttribute("aria-pressed", "true"); + await expect( + dashboard.getByRole("button", { name: "Focus Rizz, 1 shared channel" }), + ).toBeVisible(); + await expect( + dashboard.getByText("#buzz-agent-channel-map").last(), + ).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + const selector = dashboard.getByRole("combobox", { name: "Choose an agent" }); + await expect(selector).toBeVisible(); + await selector.selectOption("definition:fizz"); + await expect( + dashboard.getByRole("button", { name: "Focus Rizz, 1 shared channel" }), + ).toBeVisible(); + await expect( + page.evaluate(() => document.documentElement.scrollWidth), + ).resolves.toBe(390); + + await page.setViewportSize({ width: 1214, height: 822 }); + await dashboard.getByRole("tab", { name: /Outcomes 3/ }).click(); + await expect( + dashboard.getByRole("heading", { name: "Outcomes", exact: true }), + ).toBeVisible(); + await expect(dashboard.getByText("2", { exact: true }).first()).toBeVisible(); + await expect( + dashboard.getByRole("heading", { name: "Shipping paths" }), + ).toBeVisible(); + await expect( + dashboard.getByRole("button", { + name: /Launch the agent outcomes dashboard, Merged\. Inspect shipping path/, + }), + ).toBeVisible(); + await dashboard + .getByRole("button", { + name: /Connect signed evidence to pull requests, open\. Inspect shipping path/, + }) + .click(); + await expect( + dashboard.getByTitle("tho/outcomes-9 → block:main"), + ).toBeVisible(); + await expect(dashboard.getByText(/signed [a-f0-9]{10}…/)).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); + await expect( + dashboard.getByRole("heading", { name: "Shipping paths" }), + ).toBeVisible(); + await expect( + page.evaluate(() => document.documentElement.scrollWidth), + ).resolves.toBe(390); + expect(errors).toEqual([]); +}); diff --git a/tests/browser/layout.spec.mjs b/tests/browser/layout.spec.mjs index 9ddd1ecb..5f22e172 100644 --- a/tests/browser/layout.spec.mjs +++ b/tests/browser/layout.spec.mjs @@ -550,7 +550,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde await page.setViewportSize({ width: 1280, height: 832 }); await page.goto(app.origin); const nav = page.getByRole("navigation", { name: "Pages", exact: true }); - const titles = ["Home", "Messages", "Projects", "Agents"]; + const titles = ["Home", "Messages", "Projects", "Agent dashboard", "Agents"]; await expect(nav.getByRole("button")).toHaveText(titles); await nav.getByRole("button", { name: "Projects", exact: true }).click(); const surface = page.getByRole("region", { name: "Projects", exact: true }); @@ -592,6 +592,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde await expect(nav.getByRole("button")).toHaveText([ "Home", "Messages", + "Agent dashboard", "Agents", ]); await projects.click(); @@ -605,6 +606,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde await expect(nav.getByRole("button")).toHaveText([ "Home", "Projects", + "Agent dashboard", "Agents", ]); await channels.click(); @@ -613,6 +615,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde await expect(page.getByRole("main").getByRole("button")).toHaveText([ "Messages", "Projects", + "Agent dashboard", "Agents", "Make it yoursSettings", ]); diff --git a/tests/fixtures/agent-channels.html b/tests/fixtures/agent-channels.html new file mode 100644 index 00000000..ae433296 --- /dev/null +++ b/tests/fixtures/agent-channels.html @@ -0,0 +1,13 @@ + + + + + + + Agent dashboard + + +
+ + + diff --git a/tests/fixtures/agent-channels.tsx b/tests/fixtures/agent-channels.tsx new file mode 100644 index 00000000..24348d0f --- /dev/null +++ b/tests/fixtures/agent-channels.tsx @@ -0,0 +1,164 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { AgentChannelsPage } from "../../src/bundled/agent-channels/AgentChannelsPage"; +import { parseGitHubReference } from "../../src/bundled/github/references"; +import type { Objects } from "../../src/features/objects/service"; +import type { RelayData } from "../../src/features/relay/service"; +import type { RelaySession } from "../../src/features/relay/session"; +import { keypair, message } from "../../src/features/relay/testing"; +import "../../src/shared/styles/globals.css"; + +document.documentElement.dataset.colorMode = "dark"; + +const rizz = keypair(); +const fizz = keypair(); +const honey = keypair(); +const carl = keypair(); +const library = Object.freeze({ + status: "ready" as const, + definitions: Object.freeze([ + { id: "rizz", name: "Rizz" }, + { id: "fizz", name: "Fizz" }, + { id: "honey", name: "Honey" }, + { id: "carl", name: "Carl" }, + ]), + identities: Object.freeze([ + { pubkey: rizz.pubkey, name: "Rizz", definitionId: "rizz" }, + { pubkey: fizz.pubkey, name: "Fizz", definitionId: "fizz" }, + { pubkey: honey.pubkey, name: "Honey", definitionId: "honey" }, + { pubkey: carl.pubkey, name: "Carl", definitionId: "carl" }, + ]), +}); +const archives = Object.freeze({ status: "ready" as const, archived: [] }); +const channels = Object.freeze({ + status: "ready" as const, + channels: Object.freeze([ + { + id: "map", + name: "buzz-agent-channel-map", + channelType: "stream" as const, + members: [rizz.pubkey, carl.pubkey], + }, + { + id: "design", + name: "buzz-design", + channelType: "forum" as const, + members: [rizz.pubkey, fizz.pubkey, honey.pubkey], + }, + { + id: "history", + name: "buzz-plugin-architecture", + channelType: "stream" as const, + members: [], + }, + ]), +}); +const activity = [ + message( + rizz, + "map", + "Dashboard shipped https://github.com/block/buzz/pull/8", + 1_789_080_500, + ), + message( + carl, + "map", + "Review is up https://github.com/block/buzz/pull/9", + 1_789_080_100, + ), + message( + fizz, + "design", + "Polish pass https://github.com/block/buzz/pull/10", + 1_789_000_000, + ), + message(rizz, "history", "Earlier work", 1_788_000_000), +]; +const session = { + agentLibrary: { + snapshot: () => library, + subscribe: () => () => {}, + refresh: async () => {}, + }, + archives: { + snapshot: () => archives, + subscribe: () => () => {}, + refresh: async () => {}, + state: () => "not-archived", + }, + channels: { + list: () => channels, + subscribeList: () => () => {}, + ensureList() {}, + window: () => ({ status: "idle", rows: [] }), + subscribeWindow: () => () => {}, + ensure() {}, + loadOlder() {}, + refreshList() {}, + }, + read: async () => activity, + media: () => undefined, +} as unknown as RelaySession; +const relaySnapshot = Object.freeze({ + status: "ready" as const, + scope: "fixture", + generation: 1, + viewer: keypair().pubkey, + session, +}); +const relay = { + snapshot: () => relaySnapshot, + subscribe: () => () => {}, +} as unknown as RelayData; + +const objectProviders = Object.freeze([{ key: "buzz.github/objects" }]); +const objects = { + snapshot: () => objectProviders, + subscribe: () => () => {}, + resolve(target: string) { + const reference = parseGitHubReference(target); + return reference?.kind === "pull" + ? { + provider: { key: "buzz.github/objects" }, + reference: { + provider: "github", + key: `${reference.repository}:pull:${reference.label}`, + kind: "pull", + url: reference.url, + label: reference.label, + group: reference.repository, + }, + } + : undefined; + }, + async load(target: string) { + const pull = Number(target.split("/").at(-1)); + const reference = this.resolve(target)?.reference; + if (!reference) return; + const merged = pull !== 9; + return { + reference, + title: + pull === 8 + ? "Launch the agent outcomes dashboard" + : pull === 9 + ? "Connect signed evidence to pull requests" + : "Polish the relationship map", + state: merged ? "Merged" : "open", + author: "buzz-agent", + facts: [ + ["Branch", `tho/outcomes-${pull} → block:main`], + ["Files changed", pull], + ["Changes", `+${pull * 10} / −${pull}`], + ], + }; + }, +} as unknown as Objects; + +createRoot(document.getElementById("root") as HTMLElement).render( + +
+ +
+
, +);