diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index cc5c97ca..e730130a 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -73,6 +73,8 @@ pub fn bundled_manifests() -> Vec { .expect("agents manifest"), serde_json::from_str(include_str!("../../../src/bundled/workflows/manifest.json")) .expect("workflows manifest"), + serde_json::from_str(include_str!("../../../src/bundled/sessions/manifest.json")) + .expect("sessions manifest"), ] } fn is_bundled(id: &str) -> bool { diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index 15dbc1e5..c0f05e8a 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -273,6 +273,7 @@ fn bundled_plugins_have_independent_flags_and_all_ids_are_reserved() { ); for id in [ "buzz.terminal", + "buzz.sessions", "buzz.bestie", "buzz.projects", "buzz.agents", diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index c6818119..79e7b488 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -7,7 +7,7 @@ import { test, expect, vi, beforeEach, afterEach } from "vitest"; import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools"; import { relayBrokerPlugin } from "./relay-broker.mjs"; import { connectBrokerTransport } from "../src/features/relay/transport.ts"; -import { PublishRejected } from "../src/features/relay/outbox.ts"; +import { createOutbox, PublishRejected } from "../src/features/relay/outbox.ts"; // Only wall time is controlled. Real timers/performance.now still exercise HTTP pacing. let wallClock; @@ -18,7 +18,7 @@ beforeEach(() => { afterEach(() => vi.restoreAllMocks()); // Real browser HTTP -> production broker. Ephemeral key; upstream I/O is entirely local. -async function harness(respond) { +async function harness(respond, capabilities = {}) { const key = new Uint8Array(32); key[31] = 7; const viewer = getPublicKey(key); @@ -36,7 +36,7 @@ async function harness(respond) { relayUrl: fixtureRelayUrl, communityAliases: fixtureAliases, identity: () => key, - authority: async () => ({ relayAuthor: viewer }), + authority: async () => ({ relayAuthor: viewer, ...capabilities }), upstreamFetch: async (url, init) => { const upstreamUrl = String(url); const authorization = new Headers(init?.headers).get("Authorization"); @@ -494,3 +494,92 @@ test("both real sign and publish routes admit direct replies but reject arbitrar await h.close(); } }); + +test.each([undefined, "22222222-2222-4222-8222-222222222222"])( + "real outbox creates, invites and sends without Sessions support (parent: %s)", + async (parent) => { + const h = await harness( + (call) => + Response.json( + call.url.endsWith("/events") + ? { accepted: true, event_id: call.body.id } + : [], + ), + { channelCreation: true }, + ); + let owner; + try { + const transport = await connectBrokerTransport(h.base); + expect(transport.writer.kinds).toContain(9007); + expect(transport.writer.kinds).not.toContain(9050); + owner = createOutbox(transport.viewer, transport.writer, { + load: () => [], + save: () => {}, + }); + const id = "11111111-1111-4111-8111-111111111111"; + const creationId = owner.outbox.send({ + kind: 9007, + content: "", + tags: [ + ["h", id], + ["name", "Work"], + ["visibility", "private"], + ["channel_type", "stream"], + [ + "about", + `Buzz session (buzz.sessions/v1)${parent ? `\nparent:${parent}` : ""}`, + ], + ], + }); + await vi.waitFor(() => + expect( + owner.local.snapshot().find((row) => row.event.id === creationId) + ?.delivery, + ).toBe("accepted"), + ); + const invitationId = owner.outbox.send({ + kind: 9000, + content: "", + tags: [ + ["h", id], + ["p", "a".repeat(64)], + ], + }); + await vi.waitFor(() => + expect( + owner.local.snapshot().find((row) => row.event.id === invitationId) + ?.delivery, + ).toBe("accepted"), + ); + const messageId = owner.outbox.send({ + kind: 9, + content: "Hello", + tags: [ + ["h", id], + ["p", "a".repeat(64)], + ], + }); + await vi.waitFor(() => + expect( + owner.local.snapshot().find((row) => row.event.id === messageId) + ?.delivery, + ).toBe("accepted"), + ); + expect( + h.calls + .filter((call) => call.url.endsWith("/events")) + .map((call) => call.body.kind), + ).toEqual([9007, 9000, 9]); + const denied = await h.post("sign", { + kind: 9050, + created_at: 1700000000, + content: JSON.stringify({ action: "create", title: "Work" }), + tags: [["h", id]], + }); + expect(denied.status).toBe(400); + } finally { + owner?.dispose(); + await h.close(); + } + }, +); diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index b6e56d46..341a1b75 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -1,3 +1,4 @@ +import { validSessionCommand } from "./session-commands.mjs"; import { validateWorkflowEvent, WORKFLOW_KINDS, @@ -191,6 +192,8 @@ async function relayAuthority(fetch, relay) { throw new Error("Relay did not advertise its identity"); return { relayAuthor: author, + channelCreation: + Array.isArray(nip11.supported_nips) && nip11.supported_nips.includes(29), ...(readSnapshotCommunity(nip11.read_state_snapshot) ? { readStateCommunity: readSnapshotCommunity(nip11.read_state_snapshot) } : {}), @@ -549,7 +552,14 @@ export function relayBrokerPlugin({ viewer, ...(await getAuthority(relay)), relayUrl: relay, - writeKinds: [7, 9, ...WORKFLOW_KINDS], + writeKinds: [ + 7, + 9, + ...WORKFLOW_KINDS, + ...((await getAuthority(relay)).channelCreation + ? [9000, 9007] + : []), + ], workflowReads: true, sidebarPreferences: true, readState: true, @@ -891,7 +901,15 @@ export function relayBrokerPlugin({ const signing = route === "/api/relay/sign"; const publishing = route === "/api/relay/publish"; if (signing || publishing) { - if (![7, 9].includes(filters?.kind)) { + if ([9000, 9007].includes(filters?.kind)) { + const authority = await getAuthority(relay); + const supported = authority.channelCreation; + if (!supported || !validSessionCommand(filters)) + return json(res, 400, { + error: "Session operation unavailable or invalid", + sent: false, + }); + } else if (![7, 9].includes(filters?.kind)) { try { validateWorkflowEvent( { ...filters, pubkey: signing ? viewer : filters.pubkey }, diff --git a/dev/session-commands.mjs b/dev/session-commands.mjs new file mode 100644 index 00000000..f0ee2a1d --- /dev/null +++ b/dev/session-commands.mjs @@ -0,0 +1,51 @@ +import { sessionMetadata } from "../src/features/sessions/metadata.ts"; +// Host signing allowlist. No arbitrary kinds, roles or metadata edits. +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +export function validSessionCommand(event) { + if ( + !event || + !Number.isSafeInteger(event.created_at) || + !Array.isArray(event.tags) || + typeof event.content !== "string" + ) + return false; + if ( + !event.tags.every( + (tag) => + Array.isArray(tag) && tag.every((value) => typeof value === "string"), + ) + ) + return false; + let tags = event.tags; + if ([9000, 9007].includes(event.kind) && tags.at(-1)?.[0] === "client-id") { + const clientId = tags.at(-1); + if (clientId.length !== 2 || !uuid.test(clientId[1])) return false; + tags = tags.slice(0, -1); + } + const [h, p] = tags; + if (h?.length !== 2 || h[0] !== "h" || !uuid.test(h[1])) return false; + if (event.kind === 9007) { + const expected = ["h", "name", "visibility", "channel_type", "about"]; + return ( + event.content === "" && + tags.length === expected.length && + tags.every( + (tag, index) => tag.length === 2 && tag[0] === expected[index], + ) && + !!tags[1][1].trim() && + [...tags[1][1]].length <= 120 && + tags[2][1] === "private" && + tags[3][1] === "stream" && + sessionMetadata(tags[4][1]) !== undefined + ); + } + if (event.kind === 9000) + return ( + event.content === "" && + tags.length === 2 && + p?.length === 2 && + p[0] === "p" && + /^[0-9a-f]{64}$/.test(p[1]) + ); + return false; +} diff --git a/dev/session-commands.test.mjs b/dev/session-commands.test.mjs new file mode 100644 index 00000000..50a026bb --- /dev/null +++ b/dev/session-commands.test.mjs @@ -0,0 +1,107 @@ +import { expect, it } from "vitest"; +import { validSessionCommand } from "./session-commands.mjs"; +const id = "11111111-1111-4111-8111-111111111111"; +const event = (body, tags = [["h", id]]) => ({ + kind: 9050, + created_at: 1, + content: JSON.stringify(body), + tags, +}); +it("rejects custom session commands", () => { + expect(validSessionCommand(event({ action: "create", title: "Work" }))).toBe( + false, + ); + expect( + validSessionCommand( + event({ action: "move", parent_id: id, confirm_history: true }), + ), + ).toBe(false); +}); +it("allows only ordinary invitations, without extra roles", () => { + const invite = { + kind: 9000, + created_at: 1, + content: "", + tags: [ + ["h", id], + ["p", "a".repeat(64)], + ], + }; + expect(validSessionCommand(invite)).toBe(true); + expect( + validSessionCommand({ + ...invite, + tags: [...invite.tags, ["role", "admin"]], + }), + ).toBe(false); + expect(validSessionCommand({ ...invite, kind: 9001 })).toBe(false); +}); + +it("allows only private stream creation marked for Sessions", () => { + const create = { + kind: 9007, + created_at: 1, + content: "", + tags: [ + ["h", id], + ["name", "Work"], + ["visibility", "private"], + ["channel_type", "stream"], + ["about", "Buzz session (buzz.sessions/v1)"], + ], + }; + expect(validSessionCommand(create)).toBe(true); + const child = (description) => ({ + ...create, + tags: create.tags.map((tag) => + tag[0] === "about" ? ["about", description] : tag, + ), + }); + expect( + validSessionCommand(child(`Buzz session (buzz.sessions/v1)\nparent:${id}`)), + ).toBe(true); + for (const parent of ["invalid", `${id}\nrole:owner`, `${id}extra`]) { + expect( + validSessionCommand( + child(`Buzz session (buzz.sessions/v1)\nparent:${parent}`), + ), + ).toBe(false); + } + for (const [index, value] of [ + [2, "open"], + [3, "dm"], + [4, "arbitrary"], + [1, " "], + ]) { + expect( + validSessionCommand({ + ...create, + tags: create.tags.map((tag, i) => + i === index ? [tag[0], value] : tag, + ), + }), + ).toBe(false); + } + expect( + validSessionCommand({ + ...create, + tags: [...create.tags, ["p", "a".repeat(64)]], + }), + ).toBe(false); + expect(validSessionCommand({ ...create, content: "extra" })).toBe(false); +}); + +it("accepts one trailing outbox identifier for invitations and rejects ambiguous envelopes", () => { + const invite = (tags) => ({ kind: 9000, created_at: 1, content: "", tags }); + const h = ["h", id], + p = ["p", "a".repeat(64)], + client = ["client-id", id]; + expect(validSessionCommand(invite([h, p, client]))).toBe(true); + for (const tags of [ + [h, p, client, client], + [client, h, p], + [h, p, ["client-id", "invalid"]], + [h, p, [...client, "extra"]], + ]) + expect(validSessionCommand(invite(tags))).toBe(false); +}); diff --git a/docs/sessions/README.md b/docs/sessions/README.md new file mode 100644 index 00000000..9c276843 --- /dev/null +++ b/docs/sessions/README.md @@ -0,0 +1,60 @@ +# Sessions + +Sessions are focused work conversations built on ordinary private channels. + +## Current contract + +- A session is the work conversation. The Sessions sidebar shows previous topics, + with **New session** first and no search. Selecting one opens the conversation. +- Sessions are ordinary private stream channels. Creation, invitations, messages, + roles, and membership use existing relay behavior. **No relay changes or new + Sessions protocol are required or planned for this iteration.** +- The description marker `Buzz session (buzz.sessions/v1)` identifies sessions. + An optional newline followed by `parent:` nests a session under a channel + in the app. Signed channel metadata restores this relationship; it grants no + access and never substitutes for the session's own signed membership roster. +- Each channel has independent membership. Removing someone from a parent does + not remove them from its sessions; parent additions and roles do not propagate. + This is the accepted product behavior, not a pending inheritance rollout. +- Adding a library agent in a child session invites it to both parent and session + through normal channel invitations. Sending waits for both real rosters. Failed + invitations preserve the draft and retry the saved operation. +- New sessions can be created without a parent or beneath a Messages channel. + A channel's hover menu starts a child; its hover chevron collapses the children. + Changing a saved session's parent remains future app metadata work. +- Both entry points share the ordinary composer, centered at the bottom, and + channel-style titles. The avatar-and-name picker sits before @ and opens upward. + Explicit mentions take precedence over the selected agent. Without a selection, + a sole agent is addressed automatically; multiple agents require a recipient. +- Published replies appear in the main session conversation. Sessions uses normal + paged channel queries and complete message overlays, without new relay filters. + Existing thread replies are also presented inline. +- Detailed activity remains private to the agent's owner. Do not build a shared + activity feed for today's individually owned agents. Shared cloud agents may + introduce a different visibility model later. +- Use real conversations and agents. No sample activity, fake sends, inferred + permissions, or new execution/scheduling infrastructure. + +## Ownership and protocol + +- `src/bundled/sessions` owns the Sessions page and history navigation. +- `src/bundled/channels` owns parent-channel entry points and nested sidebar rows. +- `src/features/sessions` shares composition, recipient selection, and presentation. +- `src/features/relay/work-sessions.ts` uses the existing durable outbox for private + stream creation (kind 9007), invitations (kind 9000), and receipt recovery. +- `dev/session-commands.mjs` restricts the host signing boundary to these exact + command shapes. No custom relay operation or migration is needed. +- `session-window.ts` loads ordinary channel rows and complete message overlays + with bounded paging. Signed membership remains the authority for reads. + +## Validation and scope + +Focused tests cover signed create/invite/send, duplicate-creation recovery, +independent memberships, rejected invitations and retry, parent revocation without +child revocation, flat conversation reads, Enter submission, recipient precedence, +and agent mention rendering. Real-agent creation and replies have been exercised +in the native app. + +Moving and renaming sessions, archive/completion, sharing snapshots, and richer +prompt-linked activity remain future product work. This implementation keeps +memberships independent and detailed activity private to each agent's owner. diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 1f9860e9..1a6f1fb6 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, 4); + assert.equal(services.pages.snapshot().length, 5); await vi.waitFor(() => assert.equal(services.conversation.tools.snapshot().length, 2), ); @@ -123,7 +123,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, 4); + assert.equal(services.pages.snapshot().length, 5); await services.plugins.change("enable", "buzz.bestie"); // Management completion is not activation completion; Cordis still owns import/disposal barriers. await vi.waitFor(() => @@ -152,6 +152,22 @@ test("the app runtime exposes ready bundled pages and removes them on disable", /Connect to a community/, ); const session = services.relay.snapshot().session; + const sessionsPage = services.pages + .snapshot() + .find((page) => page.pluginId === "buzz.sessions"); + assert.equal(sessionsPage.title, "Sessions"); + assert.match( + renderToStaticMarkup(createElement(sessionsPage.component)), + /Connect to a community/, + ); + await services.plugins.change("disable", "buzz.sessions"); + assert.equal( + services.pages + .snapshot() + .some((page) => page.pluginId === "buzz.sessions"), + false, + ); + assert.equal(services.relay.snapshot().session, session); await services.plugins.change("disable", "buzz.agents"); assert.equal( services.pages.snapshot().some((page) => page.pluginId === "buzz.agents"), diff --git a/src/bundled/channels/ChannelSidebarRow.module.css b/src/bundled/channels/ChannelSidebarRow.module.css new file mode 100644 index 00000000..ce4ee718 --- /dev/null +++ b/src/bundled/channels/ChannelSidebarRow.module.css @@ -0,0 +1,96 @@ +.row { + position: relative; + display: flex; + align-items: center; + width: 100%; + min-width: 0; + border-radius: var(--radius-control); +} +.row:hover { + background: var(--surface-hover); +} +.row[data-selected] { + background: var(--surface-accent); +} +.row .select { + flex: 1; + min-width: 0; + width: auto; + padding-right: var(--space-chip-inset); +} +.row .select:is(:hover, [aria-current="page"]) { + background: transparent; +} +.row .more { + opacity: 0; + pointer-events: none; + display: grid; + place-items: center; + flex-shrink: 0; + width: 24px; + min-height: 26px; + padding: 0; + margin-right: var(--space-chip-inset); + cursor: pointer; +} +.row:is(:hover, :has(:focus-visible)) .more, +.row .more[data-popup-open] { + opacity: 1; + pointer-events: auto; +} +.row .more:hover, +.row .more[data-popup-open] { + background: var(--surface-accent); +} +.child.child { + width: 100%; +} +.iconSpace { + width: 17px; + flex: 0 0 17px; +} +.child small { + margin-left: auto; + color: var(--text-muted); + font-size: var(--text-caption); +} +.positioner { + z-index: 40; +} +.menu.menu { + position: relative; + width: max-content; + max-width: var(--available-width); + font-size: var(--text-body-sm); + outline: none; +} +.menuItem { + white-space: nowrap; + outline: none; +} +.menuItem[data-highlighted] { + background: var(--completion-highlight); +} + +.row .disclosure { + position: absolute; + left: 4px; + display: grid; + place-items: center; + width: 25px; + min-height: 26px; + padding: var(--space-1); + cursor: pointer; +} +.hashIcon { + display: flex; +} +.row .chevronIcon { + display: none; +} +.row:is(:hover, :has(:focus-visible)) .hashIcon { + display: none; +} +.row:is(:hover, :has(:focus-visible)) .chevronIcon { + display: block; +} diff --git a/src/bundled/channels/ChannelSidebarRow.test.tsx b/src/bundled/channels/ChannelSidebarRow.test.tsx new file mode 100644 index 00000000..01d433f7 --- /dev/null +++ b/src/bundled/channels/ChannelSidebarRow.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { ChannelSidebarRow } from "./ChannelSidebarRow"; + +afterEach(cleanup); +const parent = { + id: "parent", + name: "Engineering", + channelType: "stream" as const, +}; +function mount() { + const onSelect = vi.fn(), + onNewSession = vi.fn(); + function Row() { + const [collapsed, setCollapsed] = useState(false); + return ( + setCollapsed(!open)} + icon={} + badge={3} + sessions={[ + { + id: "child", + name: "Plan the release", + channelType: "session", + parentChannelId: parent.id, + }, + ]} + draft={true} + draftSelected={false} + selected="child" + onSelect={onSelect} + onPrepare={() => {}} + onNewSession={onNewSession} + /> + ); + } + render(); + return { onSelect, onNewSession }; +} +it("opens a compact action menu independently of selecting its channel", async () => { + const user = userEvent.setup(); + const callbacks = mount(); + const trigger = screen.getByRole("button", { + name: "More options for Engineering", + }); + await user.click(trigger); + await user.click( + await screen.findByRole("menuitem", { name: "New session" }), + ); + expect(callbacks.onNewSession).toHaveBeenCalledWith("parent"); + expect(callbacks.onSelect).not.toHaveBeenCalled(); + await user.click(trigger); + await user.keyboard("{Escape}"); + expect(trigger).toHaveFocus(); +}); +it("opens saved child sessions and retained drafts without a channel icon", async () => { + const user = userEvent.setup(); + const callbacks = mount(); + const child = screen.getByRole("button", { + name: "Plan the release, session in Engineering", + }); + expect(child).toHaveAttribute("aria-current", "page"); + expect(child.querySelector("svg")).toBeNull(); + await user.click(child); + expect(callbacks.onSelect).toHaveBeenCalledWith("child"); + await user.click( + screen.getByRole("button", { name: "New session draft in Engineering" }), + ); + expect(callbacks.onNewSession).toHaveBeenCalledWith("parent"); +}); + +it("collapses child sessions and drafts without navigating and expands with the keyboard", async () => { + const user = userEvent.setup(); + const callbacks = mount(); + const toggle = screen.getByRole("button", { + name: "Collapse sessions in Engineering", + }); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect( + screen.queryByRole("button", { + name: "Plan the release, session in Engineering", + }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "New session draft in Engineering" }), + ).not.toBeInTheDocument(); + expect(callbacks.onSelect).not.toHaveBeenCalled(); + expect(callbacks.onNewSession).not.toHaveBeenCalled(); + await user.keyboard("{Enter}"); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect( + screen.getByRole("button", { + name: "Plan the release, session in Engineering", + }), + ).toHaveAttribute("aria-current", "page"); +}); diff --git a/src/bundled/channels/ChannelSidebarRow.tsx b/src/bundled/channels/ChannelSidebarRow.tsx new file mode 100644 index 00000000..81140430 --- /dev/null +++ b/src/bundled/channels/ChannelSidebarRow.tsx @@ -0,0 +1,169 @@ +import { useId, useRef, type ReactNode } from "react"; +import { Menu } from "@base-ui/react/menu"; +import { ChevronDown, ChevronRight, MoreVertical } from "lucide-react"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import completion from "../../features/conversation/Completions.module.css"; +import styles from "./ChannelSidebarRow.module.css"; + +export function ChannelSidebarRow({ + channel, + icon, + badge, + selected, + sessions, + draft, + draftSelected, + collapsed, + onToggle, + onSelect, + onPrepare, + onNewSession, +}: { + channel: ChannelSummary; + icon: ReactNode; + badge?: ReactNode; + selected?: string | undefined; + sessions: readonly ChannelSummary[]; + draft: boolean; + draftSelected: boolean; + collapsed: boolean; + onToggle: (open: boolean) => void; + onSelect: (id: string) => void; + onPrepare: (id: string) => void; + onNewSession: (id: string) => void; +}) { + const starting = useRef(false); + const childrenId = useId(); + const hasChildren = draft || sessions.length > 0; + const Chevron = collapsed ? ChevronRight : ChevronDown; + const canParent = + channel.channelType !== "dm" && + channel.channelType !== "session" && + !channel.archived; + return ( + <> +
+ {hasChildren && ( + + )} + + {canParent && ( + { + if (open) starting.current = false; + }} + > + + + + + + + starting.current + ? (document + .getElementById("new-session-prompt") + ?.querySelector('[role="textbox"]') ?? + false) + : true + } + aria-label={`${channel.name} options`} + > + { + starting.current = true; + onNewSession(channel.id); + }} + > + New session + + + + + + )} +
+ + + ); +} diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 97a6c53b..e80dcdd3 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -5,6 +5,14 @@ import { buzzLinkTarget, isBuzzLink, } from "../../features/navigation/buzz-links"; +import { ChannelSidebarRow } from "./ChannelSidebarRow"; +import { SessionMessageTarget } from "../../features/sessions/SessionMessageTarget"; +import { NewSessionComposer } from "../../features/sessions/NewSessionComposer"; +import { + NewSessionView, + SessionColumn, + SessionHeading, +} from "../../features/sessions/SessionPresentation"; import { UnreadBadge, UnreadOptions } from "./UnreadBadge"; import { SidebarUnread } from "./SidebarUnread"; import type { ConversationExtensions } from "../../features/conversation/contracts"; @@ -156,8 +164,23 @@ function ChannelWorkspace({ const [selected, setSelected] = useState(() => readView(scope, "selected-channel", undefined), ); + const [draftParent, setDraftParent] = useState(); + const [draftParents, setDraftParents] = useState(() => { + const saved = readView(scope, "sessions:channel-drafts", []); + return Array.isArray(saved) + ? saved.filter((id): id is string => typeof id === "string") + : []; + }); + const updateDraftParents = (update: (previous: string[]) => string[]) => { + setDraftParents((previous) => { + const next = update(previous); + writeView(scope, "sessions:channel-drafts", next); + return next; + }); + }; const select = useCallback( (id: string) => { + setDraftParent(undefined); if (navigator && viewer) { void navigator.open({ version: 1, @@ -186,6 +209,21 @@ function ChannelWorkspace({ ); const { search } = sidebar; const channels = useChannelLabels(list.channels, queries.profiles); + const childrenByParent = useMemo(() => { + const children = new Map(); + for (const item of channels) { + if (item.channelType !== "session" || !item.parentChannelId) continue; + const siblings = children.get(item.parentChannelId) ?? []; + siblings.push(item); + children.set(item.parentChannelId, siblings); + } + for (const siblings of children.values()) + siblings.sort( + (a, b) => + (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || a.id.localeCompare(b.id), + ); + return children; + }, [channels]); const requestedChannel = navigation?.target.kind === "conversation" ? navigation.target.channelId @@ -195,7 +233,8 @@ function ChannelWorkspace({ (list.coverage === "partial" ? { id: requestedChannel, name: "Conversation" } : undefined)) - : (channels.find((channel) => channel.id === selected) ?? channels[0]); + : (channels.find((channel) => channel.id === selected) ?? + channels.find((item) => item.channelType !== "session")); useEffect(() => { if (navigation?.signal.aborted) return; if (requestedChannel && list.status === "ready" && !current) @@ -224,6 +263,27 @@ function ChannelWorkspace({ ? navigation.target.threadRootId : undefined; const currentId = current?.id; + const drafting = + !!draftParent && draftParent === currentId && !requestedMessage; + const startSession = (parentId: string) => { + select(parentId); + setDraftParent(parentId); + sidebar.toggle(`session-children:${parentId}`, true); + updateDraftParents((previous) => + previous.includes(parentId) ? previous : [...previous, parentId], + ); + setThread(undefined); + open(undefined); + }; + useEffect(() => { + if ( + drafting && + navigation?.target.kind === "conversation" && + !navigation.target.messageId + ) + navigation.complete({ status: "opened" }); + }, [drafting, navigation]); + const flatSession = current?.channelType === "session"; const [exactOpening, setExactOpening] = useState<{ request: PageNavigation; inTimeline: boolean; @@ -232,7 +292,7 @@ function ChannelWorkspace({ if ( !navigation || !requestedMessage || - requestedThread === requestedMessage || + (!flatSession && requestedThread === requestedMessage) || !currentId || navigation.signal.aborted ) @@ -248,20 +308,31 @@ function ChannelWorkspace({ setExactOpening({ request: navigation, inTimeline: - requestedThread !== requestedMessage && + (flatSession || requestedThread !== requestedMessage) && window.status === "ready" && window.freshness !== "cached" && window.rows.some( - (row) => row.id === requestedMessage && !row.threadRootId, + (row) => + row.id === requestedMessage && (flatSession || !row.threadRootId), ), }); }; const stop = queries.channels.subscribeWindow(currentId, choose); choose(); return stop; - }, [navigation, requestedMessage, requestedThread, currentId, queries]); + }, [ + navigation, + requestedMessage, + requestedThread, + currentId, + queries, + flatSession, + ]); const exact = - navigation && requestedMessage && requestedThread === requestedMessage + !flatSession && + navigation && + requestedMessage && + requestedThread === requestedMessage ? { request: navigation, inTimeline: false } : exactOpening?.request === navigation ? exactOpening @@ -279,6 +350,10 @@ function ChannelWorkspace({ : thread && thread.channelId === current?.id ? { ...thread, navigation: undefined } : undefined; + if (flatSession) { + showingThread = undefined; + priorRoutedThread.current = undefined; + } if (showingThread?.navigation) priorRoutedThread.current = showingThread; else if (!showingThread && (!navigation || (requestedMessage && !exact))) showingThread = priorRoutedThread.current; @@ -469,10 +544,16 @@ function ChannelWorkspace({ const drawer = useChannelPanels(panels, drawerContext); const visible = useMemo( () => - channels.filter((channel) => - channel.name.toLowerCase().includes(search.toLowerCase()), + channels.filter( + (channel) => + channel.name.toLowerCase().includes(search.toLowerCase()) || + childrenByParent + .get(channel.id) + ?.some((child) => + child.name.toLowerCase().includes(search.toLowerCase()), + ), ), - [channels, search], + [channels, search, childrenByParent], ); return (
} + badge={ + } - onPointerEnter={() => - queries.channels.prepare?.(channel.id) + selected={current?.id} + collapsed={sidebar.collapsed.includes( + `session-children:${channel.id}`, + )} + onToggle={(open) => + sidebar.toggle(`session-children:${channel.id}`, open) } - onFocus={() => queries.channels.prepare?.(channel.id)} - onClick={() => select(channel.id)} - > - - {channel.name} - - + draft={draftParents.includes(channel.id)} + draftSelected={drafting && draftParent === channel.id} + sessions={(childrenByParent.get(channel.id) ?? []).filter( + (child) => + channel.name + .toLowerCase() + .includes(search.toLowerCase()) || + child.name.toLowerCase().includes(search.toLowerCase()), + )} + onPrepare={(id) => queries.channels.prepare?.(id)} + onSelect={select} + onNewSession={startSession} + /> ); })} @@ -564,118 +654,186 @@ function ChannelWorkspace({ )}
-
-
- {current?.channelType === "dm" ? ( - + {drafting && current ? ( + + { + updateDraftParents((previous) => + previous.filter((parent) => parent !== current.id), + ); + select(id); + }} + /> + + ) : ( + <> + {current?.channelType === "session" ? ( + parent.id === current.parentChannelId, + )?.name + } + /> ) : ( - +
+
+ {current?.channelType === "dm" ? ( + + ) : ( + + )} + {current?.name ?? "Channels"} +
+ {drawer.launchers} +
+ + +
+ +
+ Diagnostics + +

+ {list.coverage === "partial" + ? "Partial roster" + : "Roster"}{" "} + · {channels.length} channels +

+ + {preferences.error && ( +

Saved groups and stars: {preferences.error}

+ )} + {preferences.status !== "unsupported" && ( + + )} + {current && ( + + )} + {queries.outbox ? ( + + ) : ( + + )} +
+
+
+
)} - {current?.name ?? "Channels"} -
- {drawer.launchers} -
- - -
- -
- Diagnostics - + + {flatSession && + current && + navigation && + requestedMessage && + exact && + !exact.inTimeline ? ( + select(current.id)} + onRetry={() => { + void navigator?.retry(); + }} /> -

- {list.coverage === "partial" ? "Partial roster" : "Roster"} ·{" "} - {channels.length} channels -

- - {preferences.error && ( -

Saved groups and stars: {preferences.error}

- )} - {preferences.status !== "unsupported" && ( - - )} - {current && ( - - )} - {queries.outbox ? ( - - ) : ( - - )} -
-
-
-
- - {current ? ( - - ) : ( -
Select a channel to read it.
- )} - {current && ( - setSent({ channelId: current.id, id })} - /> + ) : current ? ( + + ) : ( +
Select a channel to read it.
+ )} + {current && ( + { + setSent({ channelId: current.id, id }); + if (flatSession && requestedMessage) select(current.id); + }} + /> + )} + + {drawer.content} + )} - {drawer.content}
{(panel || showingThread || companion) && (
{showingThread && ( boolean) | undefined; revealMessageId?: string | undefined; - onOpenThread(messageId: string, threadRootId: string): void; + onOpenThread?: + | ((messageId: string, threadRootId: string) => void) + | undefined; }) { const window = useChannelWindow(queries.channels, channelId); useEffect(() => { @@ -774,7 +934,7 @@ const ChannelBody = memo(function ChannelBody({ window={window} onOpenLink={onOpenLink} canOpenLink={canOpenLink} - onOpenThread={onOpenThread} + {...(onOpenThread ? { onOpenThread } : {})} revealMessageId={revealMessageId} navigation={navigation} /> diff --git a/src/bundled/channels/sidebar-sections.test.ts b/src/bundled/channels/sidebar-sections.test.ts index d06d1171..4a1d89f1 100644 --- a/src/bundled/channels/sidebar-sections.test.ts +++ b/src/bundled/channels/sidebar-sections.test.ts @@ -16,6 +16,7 @@ it("intersects groups/stars with active authorized streams, keeping forums and D row("dm", { channelType: "dm", hidden: true }), row("group-dm", { channelType: "dm", participants: ["a", "b"] }), row("forum", { channelType: "forum" }), + row("session", { channelType: "session" }), ]; const preferences = { sections: [{ id: "channels", name: "Channels", order: 0 }], @@ -26,7 +27,15 @@ it("intersects groups/stars with active authorized streams, keeping forums and D "group-dm": "channels", other: "missing", }, - starred: ["star", "archived", "hidden", "revoked", "dm", "forum"], + starred: [ + "star", + "archived", + "hidden", + "revoked", + "dm", + "forum", + "session", + ], }; const project = (channels: readonly ChannelSummary[]) => sidebarSections(channels, preferences).map((section) => [ diff --git a/src/bundled/channels/sidebar-sections.ts b/src/bundled/channels/sidebar-sections.ts index c9546550..aaee24e1 100644 --- a/src/bundled/channels/sidebar-sections.ts +++ b/src/bundled/channels/sidebar-sections.ts @@ -8,7 +8,9 @@ export function sidebarSections( ) { const active = channels.filter( (channel) => - !channel.archived && (!channel.hidden || channel.channelType === "dm"), + !channel.archived && + channel.channelType !== "session" && + (!channel.hidden || channel.channelType === "dm"), ); const streams = active.filter( (channel) => diff --git a/src/bundled/index.ts b/src/bundled/index.ts index ec42f01c..a07ef360 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -23,6 +23,8 @@ import * as workflows from "./workflows"; import type { BundledPlugin } from "../plugins/manager"; import linksManifest from "./links/manifest.json"; import * as links from "./links"; +import sessionsManifest from "./sessions/manifest.json"; +import * as sessions from "./sessions"; export const bundledPlugins: readonly BundledPlugin[] = [ { manifest: { ...activityManifest, apiVersion: 1 }, module: activity }, @@ -37,4 +39,5 @@ export const bundledPlugins: readonly BundledPlugin[] = [ { manifest: { ...projectsManifest, apiVersion: 1 }, module: projects }, { manifest: { ...agentsManifest, apiVersion: 1 }, module: agents }, { manifest: { ...workflowsManifest, apiVersion: 1 }, module: workflows }, + { manifest: { ...sessionsManifest, apiVersion: 1 }, module: sessions }, ]; diff --git a/src/bundled/mentions/MentionCompletion.tsx b/src/bundled/mentions/MentionCompletion.tsx index 9fbf7869..cbcfcb49 100644 --- a/src/bundled/mentions/MentionCompletion.tsx +++ b/src/bundled/mentions/MentionCompletion.tsx @@ -1,3 +1,4 @@ +import { useAgentChoices } from "./use-agent-choices"; import { useEffect, useState, useSyncExternalStore } from "react"; import type { ComposerCompletionProps } from "../../features/conversation/contracts"; import type { RelaySession } from "../../features/relay/session"; @@ -10,6 +11,7 @@ const demands = new WeakMap>(); export function MentionCompletion({ session, channelId, + inviteAgents, query, publish, }: ComposerCompletionProps) { @@ -23,7 +25,11 @@ export function MentionCompletion({ session.profiles.snapshot, session.profiles.snapshot, ); + const agents = useAgentChoices(session, inviteAgents); const channel = list.channels.find((item) => item.id === channelId); + const parentAdmission = + !!channel && + (channel.channelType !== "session" || !!channel.parentChannelId); const members = channel?.members ?? []; const memberKey = members.join(":"); const [attempt, retry] = useState(0); @@ -48,10 +54,21 @@ export function MentionCompletion({ }, [session, memberKey, attempt]); useEffect(() => { const members = memberKey ? memberKey.split(":") : []; - const candidates = members.map((pubkey) => ({ - pubkey, - name: profiles.get(pubkey)?.name ?? pubkey.slice(0, 12), - })); + const choices = new Map( + agents.identities.map((agent) => [ + agent.pubkey, + { pubkey: agent.pubkey, name: agent.name }, + ]), + ); + for (const pubkey of members) + choices.set(pubkey, { + pubkey, + name: + profiles.get(pubkey)?.name ?? + choices.get(pubkey)?.name ?? + pubkey.slice(0, 12), + }); + const candidates = [...choices.values()]; const needle = query.query.toLowerCase(); const admitted = matchesMentionQuery( query.query, @@ -71,12 +88,15 @@ export function MentionCompletion({ a.pubkey.localeCompare(b.pubkey), ) : []; + const membershipMissing = (!inviteAgents || !!channel) && !channel?.members; const missing = members.some((key) => !profiles.has(key)); const withdraw = publish({ items: matching.slice(0, 20).map((recipient) => ({ id: recipient.pubkey, label: recipient.name, - detail: recipient.pubkey, + detail: !members.includes(recipient.pubkey) + ? `${parentAdmission ? "Adds to session and parent channel" : "Adds to session"} · ${recipient.pubkey}` + : recipient.pubkey, preview: ( 20 - ? { status: "Narrow your search to see more members." } - : {}), - ...(!channel?.members || list.error || error || missing + ...(agents.status === "error" + ? { status: "Could not load agents. Retry to refresh." } + : admitted && membershipMissing + ? { status: "Channel membership unavailable." } + : admitted && list.error + ? { status: "Could not refresh channel membership." } + : error || missing + ? { + status: + "Some names unavailable. Exact public keys still identify recipients.", + } + : matching.length > 20 + ? { status: "Narrow your search to see more members." } + : {}), + ...(agents.status === "error" || + membershipMissing || + list.error || + error || + missing ? { retry: () => { + if (inviteAgents) void session.agentLibrary.refresh(); setError(false); retry((value) => value + 1); - if (!channel?.members || list.error) + if (membershipMissing || list.error) session.channels.refreshList?.(); }, } @@ -129,16 +156,23 @@ export function MentionCompletion({ const profilesChanged = session.profiles.subscribe(() => { if (session.profiles.snapshot() !== profiles) revoke(); }); + const agentsChanged = inviteAgents + ? session.agentLibrary.subscribe(revoke) + : () => {}; return () => { + agentsChanged(); rosterChanged(); profilesChanged(); revoke(); }; }, [ session, + agents, + inviteAgents, channel, channelId, memberKey, + parentAdmission, profiles, query.query, publish, diff --git a/src/bundled/mentions/MentionPicker.tsx b/src/bundled/mentions/MentionPicker.tsx index b2f17a79..2cc45e43 100644 --- a/src/bundled/mentions/MentionPicker.tsx +++ b/src/bundled/mentions/MentionPicker.tsx @@ -1,3 +1,4 @@ +import { useAgentChoices } from "./use-agent-choices"; import { Avatar } from "../../shared/Avatar"; import { AtSign } from "lucide-react"; import { @@ -17,11 +18,13 @@ export function MentionPicker({ session, channelId, disabled, + inviteAgents, select, }: { session: RelaySession; channelId: string; disabled: boolean; + inviteAgents?: boolean | undefined; select: ComposerToolProps["insertMention"]; }) { const [open, setOpen] = useState(false); @@ -39,7 +42,11 @@ export function MentionPicker({ session.profiles.snapshot, session.profiles.snapshot, ); + const agents = useAgentChoices(session, inviteAgents && open); const channel = list.channels.find((item) => item.id === channelId); + const parentAdmission = + !!channel && + (channel.channelType !== "session" || !!channel.parentChannelId); const memberKey = channel?.members?.join(":") ?? ""; useEffect(() => { if (!open || !memberKey) return; @@ -56,14 +63,23 @@ export function MentionPicker({ current = false; }; }, [session, open, memberKey]); - const candidates = (channel?.members ?? []) - .map((pubkey) => ({ + const choices = new Map( + agents.identities.map((agent) => [ + agent.pubkey, + { pubkey: agent.pubkey, name: agent.name }, + ]), + ); + for (const pubkey of channel?.members ?? []) + choices.set(pubkey, { pubkey, - name: profiles.get(pubkey)?.name ?? pubkey.slice(0, 12), - })) - .filter(({ name, pubkey }) => - `${name} ${pubkey}`.toLowerCase().includes(search.trim().toLowerCase()), - ); + name: + profiles.get(pubkey)?.name ?? + choices.get(pubkey)?.name ?? + pubkey.slice(0, 12), + }); + const candidates = [...choices.values()].filter(({ name, pubkey }) => + `${name} ${pubkey}`.toLowerCase().includes(search.trim().toLowerCase()), + ); return (
-

Only members of this channel are shown.

+

+ {inviteAgents + ? parentAdmission + ? "Agents you mention join this session and its parent channel when you send, with access to their history." + : "Agents you mention join this session when you send, with access to its history." + : "Only members of this channel are shown."} +

+ {agents.status === "loading" &&

Loading agents…

} + {agents.status === "error" && ( + + )} {error &&

{error}

} {list.error && (

Could not refresh channel membership.

)} - {!channel?.members && ( + {(!inviteAgents || !!channel) && !channel?.members && (

Channel membership unavailable.

)} + + )} + + ); +} + +function LiveSessions({ + session, + scope, + extensions, +}: { + session: RelaySession; + scope: string; + extensions: ConversationExtensions; +}) { + const list = useChannelList(session.channels); + const [selected, setSelected] = useState(() => { + const saved = readView(scope, "sessions:selected", ""); + return typeof saved === "string" ? saved : ""; + }); + const selectedSession = list.channels.find( + (item) => item.id === selected && item.channelType === "session", + ); + const sessions = list.channels + .filter((item) => item.channelType === "session" && !item.archived) + .sort( + (a, b) => + (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || a.id.localeCompare(b.id), + ); + const select = (id: string) => { + setSelected(id); + writeView(scope, "sessions:selected", id); + }; + return ( + ({ + id: item.id, + title: item.name, + ...(item.parentChannelId + ? { + parentName: + list.channels.find( + (parent) => parent.id === item.parentChannelId, + )?.name ?? "Channel session", + } + : {}), + }))} + selected={selected} + onSelect={select} + onNew={() => { + select(""); + }} + listStatus={ + list.status === "loading" ? ( +

Loading sessions…

+ ) : list.status === "error" ? ( +
+

{list.error ?? "Sessions couldn’t load."}

+ +
+ ) : undefined + } + > + {selectedSession ? ( + item.id === selectedSession.parentChannelId, + )?.name + } + /> + ) : ( + + + + )} +
+ ); +} + +function SessionWork({ + session, + scope, + channel, + extensions, + parentName, +}: { + session: RelaySession; + scope: string; + channel: ChannelSummary; + extensions: ConversationExtensions; + parentName?: string | undefined; +}) { + const window = useChannelWindow(session.channels, channel.id); + const [sent, setSent] = useState(); + const openLink = () => false; + return ( +
+ + +
+ {window.status === "error" && !window.rows.length ? ( +
+

{window.error}

+ +
+ ) : window.status !== "ready" && !window.rows.length ? ( +

+ Loading messages… +

+ ) : ( + + )} +
+ +
+
+ ); +} diff --git a/src/bundled/sessions/SessionsWorkspace.module.css b/src/bundled/sessions/SessionsWorkspace.module.css new file mode 100644 index 00000000..3ba2db20 --- /dev/null +++ b/src/bundled/sessions/SessionsWorkspace.module.css @@ -0,0 +1,127 @@ +.workspace { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + gap: var(--space-2); + height: 100%; + min-height: 0; + color: var(--text); +} + +.sidebar, +.content { + background: var(--surface); + border-radius: var(--radius-card); + min-width: 0; + min-height: 0; +} + +.sidebar { + display: flex; + flex-direction: column; + padding: var(--space-3); +} + +.newSession { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-3) var(--space-3); + border-radius: var(--radius-control); + background: var(--selected); + color: var(--on-selected); + font-weight: var(--type-weight-medium); + cursor: pointer; +} + +.newSession:hover { + filter: brightness(0.97); +} + +.historyHeading { + margin: var(--space-6) var(--space-3) var(--space-2); + color: var(--text-muted); + font-size: var(--text-caption); + font-weight: var(--type-weight-medium); +} + +.history { + overflow-y: auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: var(--space-chip-inset); +} + +.history button { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-1); + padding: var(--space-3); + border-radius: var(--radius-control); + text-align: left; + cursor: pointer; +} + +.history button:hover { + background: var(--surface-hover); +} + +.history button[aria-current] { + background: var(--selected); + color: var(--on-selected); +} + +.history span { + overflow-wrap: anywhere; + line-height: 1.4; +} + +.history small, +.listMessage { + color: var(--text-muted); + font-size: var(--text-caption); + line-height: 1.6; +} + +.parentChannel { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.parentChannel svg { + flex-shrink: 0; +} + +.listMessage { + margin: var(--space-1) var(--space-3); +} + +.content { + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media (max-width: 760px) { + .workspace { + grid-template-columns: 200px minmax(0, 1fr); + } +} + +@media (max-width: 560px) { + .workspace { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(120px, 30%) minmax(0, 1fr); + } + + .sidebar { + padding: var(--space-2); + } + + .historyHeading { + display: none; + } +} diff --git a/src/bundled/sessions/SessionsWorkspace.tsx b/src/bundled/sessions/SessionsWorkspace.tsx new file mode 100644 index 00000000..9036d9b8 --- /dev/null +++ b/src/bundled/sessions/SessionsWorkspace.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { Hash } from "lucide-react"; +import { IconPlus } from "@tabler/icons-react"; +import styles from "./SessionsWorkspace.module.css"; + +/** Presentation only. The caller supplies authorized, saved sessions. */ +export type SessionListItem = Readonly<{ + id: string; + title: string; + parentName?: string; +}>; + +export function SessionsWorkspace({ + sessions, + selected, + onSelect, + onNew, + listStatus, + children, +}: { + sessions: readonly SessionListItem[]; + selected?: string; + onSelect: (id: string) => void; + onNew: () => void; + listStatus?: ReactNode; + children: ReactNode; +}) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/bundled/sessions/index.tsx b/src/bundled/sessions/index.tsx new file mode 100644 index 00000000..0bc0566b --- /dev/null +++ b/src/bundled/sessions/index.tsx @@ -0,0 +1,14 @@ +import type { PluginModule } from "../../plugins/api"; +import { SessionsPage } from "./SessionsPage"; + +export const inject = ["pages", "relay", "conversation"]; +export const apply: PluginModule["apply"] = (ctx) => { + const relay = ctx.relay; + const extensions = ctx.conversation; + ctx.pages.register({ + id: "sessions", + title: "Sessions", + layout: "workspace", + component: () => , + }); +}; diff --git a/src/bundled/sessions/manifest.json b/src/bundled/sessions/manifest.json new file mode 100644 index 00000000..73101bd0 --- /dev/null +++ b/src/bundled/sessions/manifest.json @@ -0,0 +1 @@ +{ "id": "buzz.sessions", "name": "Sessions", "apiVersion": 1 } diff --git a/src/features/agents/library.test.ts b/src/features/agents/library.test.ts index a4360c12..f5c2cf8c 100644 --- a/src/features/agents/library.test.ts +++ b/src/features/agents/library.test.ts @@ -50,6 +50,7 @@ it("lazy fresh reads replace, fail visibly, retry, and fence late results", asyn }), ); const pending = owner.queries.refresh(); + expect(owner.queries.snapshot().identities).toEqual(library.identities); await Promise.resolve(); owner.clear(); release(library); diff --git a/src/features/agents/library.ts b/src/features/agents/library.ts index 6efa3f68..7b4c68dc 100644 --- a/src/features/agents/library.ts +++ b/src/features/agents/library.ts @@ -14,7 +14,7 @@ export type AgentLibrary = Readonly<{ }>[]; }>; export type AgentLibraryReader = (signal: AbortSignal) => Promise; -type Snapshot = AgentLibrary & +export type AgentLibrarySnapshot = AgentLibrary & Readonly<{ status: "unavailable" | "idle" | "loading" | "ready" | "error"; error?: string; @@ -27,9 +27,12 @@ export function createAgentLibrary( let closed = false; let controller: AbortController | undefined; let pending: Promise | undefined; - let snapshot: Snapshot = { ...empty, status: read ? "idle" : "unavailable" }; + let snapshot: AgentLibrarySnapshot = { + ...empty, + status: read ? "idle" : "unavailable", + }; const listeners = new Set<() => void>(); - function publish(next: Snapshot) { + function publish(next: AgentLibrarySnapshot) { snapshot = Object.freeze(next); for (const listener of listeners) notify(listener); } @@ -38,7 +41,7 @@ export function createAgentLibrary( if (pending) return pending; const owned = new AbortController(); controller = owned; - publish({ ...empty, status: "loading" }); + publish({ ...snapshot, status: "loading" }); pending = Promise.resolve() .then(() => { if (closed || owned.signal.aborted) @@ -52,7 +55,7 @@ export function createAgentLibrary( .catch(() => { if (!closed && !owned.signal.aborted) publish({ - ...empty, + ...snapshot, status: "error", error: "Could not read the current Buzz agent library. Open Buzz and retry; its saved library is left unchanged.", diff --git a/src/features/conversation/contracts.ts b/src/features/conversation/contracts.ts index 98e83dd9..fd63e9ec 100644 --- a/src/features/conversation/contracts.ts +++ b/src/features/conversation/contracts.ts @@ -9,6 +9,8 @@ export type ComposerToolProps = Readonly<{ scope: string; channelId: string; threadRootId?: string | undefined; + /** Sessions may offer library agents; the host confirms channel admission before sending. */ + inviteAgents?: boolean | undefined; disabled: boolean; /** False after removal, destination change, read-only state or a rejected edit. */ insertText(text: string): boolean; @@ -79,7 +81,7 @@ export type ComposerObservation = Readonly<{ }>; export type CompletionContext = Pick< ComposerToolProps, - "session" | "scope" | "channelId" | "threadRootId" + "session" | "scope" | "channelId" | "threadRootId" | "inviteAgents" >; export type CompletionQuery = Readonly<{ start: number; diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index aa09520d..392d19ab 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -8,6 +8,7 @@ import { render, screen, within, + waitFor, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useLayoutEffect } from "react"; @@ -110,7 +111,11 @@ function mount(options: Partial = {}) { const session = { messages, typing: { snapshot: () => typing, subscribe: () => () => {} }, - profiles: { snapshot: () => profiles, subscribe: () => () => {} }, + profiles: { + snapshot: () => profiles, + subscribe: () => () => {}, + ensure: vi.fn(async () => {}), + }, emoji: { snapshot: () => emoji, subscribe(listener: () => void) { @@ -583,3 +588,205 @@ it("rejects overlong and over-limit tool edits without changing accepted intent" expect(h.input()).toHaveValue(before); expect(screen.getByRole("alert")).toHaveTextContent("at most 32 recipients"); }); + +it.each( + ["send", "unmount", "disabled", "denied"].flatMap((outcome) => + ["mention", "avatar"].flatMap((recipient) => + [true, false].map((parent) => ({ + outcome, + recipient, + parent, + })), + ), + ), +)( + "waits for agent admission before saved session messages: $recipient / $outcome / parent=$parent", + async ({ outcome, recipient, parent }) => { + const view = mount(); + const list = { + status: "ready", + channels: [ + { + id: "channel", + channelType: "session", + ...(parent ? { parentChannelId: "parent" } : {}), + members: [] as string[], + }, + ], + }; + const library = { status: "ready", definitions: [], identities: [first] }; + let release = () => {}; + const addAgents = vi.fn( + () => + new Promise((resolve, reject) => { + release = () => { + if (outcome === "denied") reject(new Error("Cannot add agents")); + else { + list.channels[0]?.members.push(first.pubkey); + resolve(); + } + }; + }), + ); + const session = { + ...view.session, + channels: { list: () => list, subscribeList: () => () => {} }, + agentLibrary: { + snapshot: () => library, + subscribe: () => () => {}, + refresh: async () => {}, + }, + workSessions: { + addAgents, + refreshMembership: vi.fn(async () => list.channels[0]), + }, + } as unknown as RelaySession; + view.retarget({ session, sessionConversation: true }); + expect(view.commands().inviteAgents).toBe(true); + if (recipient === "mention") { + await view.user.click( + screen.getByRole("button", { name: "First Honey" }), + ); + } else { + await view.user.type(view.input(), "Hello Honey"); + await view.user.click( + screen.getByRole("button", { name: "Choose an agent" }), + ); + await view.user.click( + await screen.findByRole("menuitemradio", { + name: parent ? "Honey — adds to channel" : "Honey", + }), + ); + } + expect(addAgents).not.toHaveBeenCalled(); + expect(session.workSessions.refreshMembership).not.toHaveBeenCalled(); + expect(view.messages.send).not.toHaveBeenCalled(); + view.submit(); + await waitFor(() => + expect(addAgents).toHaveBeenCalledWith( + "channel", + [first.pubkey], + expect.any(Function), + ), + ); + expect(view.messages.send).not.toHaveBeenCalled(); + if (outcome === "unmount") view.unmount(); + if (outcome === "disabled") view.retarget({ disabled: true }); + await act(async () => release()); + if (outcome === "send") { + await waitFor(() => expect(view.messages.send).toHaveBeenCalledOnce()); + expect(addAgents).toHaveBeenCalledOnce(); + } else expect(view.messages.send).not.toHaveBeenCalled(); + if (outcome === "denied") { + expect(await screen.findByRole("alert")).toHaveTextContent( + "Cannot add agents", + ); + expect(view.input()).toHaveTextContent("Honey"); + } + }, +); + +it("routes to the avatar choice and lets an explicit mention override it", async () => { + const view = mount(); + const library = { + status: "ready", + definitions: [], + identities: [first, { ...second, name: "Fizz" }], + }; + const list = { + status: "ready", + channels: [ + { + id: "channel", + channelType: "session", + members: [first.pubkey, second.pubkey], + }, + ], + }; + const session = { + ...view.session, + channels: { list: () => list, subscribeList: () => () => {} }, + workSessions: { + refreshMembership: vi.fn(async () => list.channels[0]), + addAgents: vi.fn(async () => {}), + }, + agentLibrary: { + snapshot: () => library, + subscribe: () => () => {}, + refresh: async () => {}, + }, + } as unknown as RelaySession; + view.retarget({ session, sessionConversation: true }); + await view.user.click( + screen.getByRole("button", { name: "Choose an agent" }), + ); + await view.user.click( + await screen.findByRole("menuitemradio", { name: "Fizz" }), + ); + await view.user.type(view.input(), "Hello"); + await view.user.keyboard("{Enter}"); + expect(view.messages.send).toHaveBeenLastCalledWith("channel", "Hello", [ + second.pubkey, + ]); + await view.user.click(screen.getByRole("button", { name: "First Honey" })); + view.submit(); + await waitFor(() => + expect(view.messages.send).toHaveBeenLastCalledWith( + "channel", + expect.any(String), + [first.pubkey], + ), + ); + expect(session.workSessions.addAgents).not.toHaveBeenCalled(); +}); + +it.each(["ready", "failed", "unmounted"])( + "refreshes cached membership before sending an existing mention: %s", + async (outcome) => { + const view = mount(); + const channel = { + id: "channel", + channelType: "session", + members: [first.pubkey], + }; + const list = { status: "ready", channels: [channel] }; + const library = { status: "ready", identities: [first] }; + let release = () => {}; + const refreshMembership = vi.fn( + () => + new Promise((resolve, reject) => { + release = () => + outcome === "failed" + ? reject(new Error("Could not refresh channel membership")) + : resolve(channel); + }), + ); + const addAgents = vi.fn(); + const session = { + ...view.session, + channels: { list: () => list, subscribeList: () => () => {} }, + agentLibrary: { snapshot: () => library, subscribe: () => () => {} }, + workSessions: { refreshMembership, addAgents }, + } as unknown as RelaySession; + view.retarget({ session, sessionConversation: true }); + await view.user.click(screen.getByRole("button", { name: "First Honey" })); + view.submit(); + await waitFor(() => + expect(refreshMembership).toHaveBeenCalledWith("channel"), + ); + expect(view.messages.send).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(); + if (outcome === "unmounted") view.unmount(); + await act(async () => release()); + expect(addAgents).not.toHaveBeenCalled(); + if (outcome === "ready") expect(view.messages.send).toHaveBeenCalledOnce(); + else expect(view.messages.send).not.toHaveBeenCalled(); + if (outcome === "failed") { + expect(screen.getByRole("alert")).toHaveTextContent("Could not refresh"); + expect(view.input()).toHaveTextContent("Honey"); + expect( + screen.getByRole("button", { name: "Send message" }), + ).toBeEnabled(); + } + }, +); diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 97f4181f..885622f8 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -1,3 +1,4 @@ +import { SessionAgentControl } from "../sessions/SessionAgentControl"; import { TypingIndicator } from "./TypingIndicator"; import { ArrowUp, X } from "lucide-react"; import { @@ -7,6 +8,7 @@ import { useRef, useState, useSyncExternalStore, + type ReactNode, } from "react"; import type { RelaySession } from "../relay/session"; import { readView, writeView } from "../../shared/view-state"; @@ -39,27 +41,46 @@ import { useCompletionEditor } from "../conversation/useCompletionEditor"; import { RichComposerInput } from "./RichComposerInput"; import type { ComposerInputElement } from "./composer-dom"; +const noChannels: ReturnType = { + status: "idle", + channels: [], +}; +const noChannelSnapshot = () => noChannels; +const noChannelSubscription = () => () => {}; + export type MessageComposerProps = { extensions?: ConversationExtensions | undefined; scope: string; session: RelaySession; channelId: string; channelName: string; + label?: string | undefined; + sessionConversation?: boolean | undefined; + trailingTool?: ReactNode; + inviteAgents?: boolean | undefined; onSend?: (id: string) => void; threadRootId?: string; disabled?: boolean; + /** A new conversation owns persistence and delivery before a channel exists. */ + submission?: { + draftKey: string; + initialDraft?: MentionDraft | string | undefined; + locked: boolean; + disabled: boolean; + submit: (draft: MentionDraft) => void; + }; }; /** Safe to retarget through ordinary props; callers do not own internal remount keys. */ export function MessageComposer(props: MessageComposerProps) { return ( ); @@ -70,17 +91,52 @@ function Composer({ scope, channelId, channelName, + label: customLabel, onSend, threadRootId, disabled = false, + submission, + sessionConversation, + inviteAgents = false, + trailingTool, }: MessageComposerProps) { const inputId = useId(); - const draftKey = threadRootId - ? `draft:${channelId}:thread:${threadRootId}` - : `draft:${channelId}`; - const label = threadRootId ? "Reply to thread" : `Message #${channelName}`; + const draftKey = + submission?.draftKey ?? + (threadRootId + ? `draft:${channelId}:thread:${threadRootId}` + : `draft:${channelId}`); + const [selectedAgent, setSelectedAgent] = useState(""); + const [admitting, setAdmitting] = useState(false); + const admission = useRef(false); + const live = useRef(true); + const permitted = useRef(!disabled); + permitted.current = !disabled; + useEffect(() => { + live.current = true; + return () => { + live.current = false; + }; + }, []); + const list = useSyncExternalStore( + sessionConversation + ? session.channels.subscribeList + : noChannelSubscription, + sessionConversation ? session.channels.list : noChannelSnapshot, + sessionConversation ? session.channels.list : noChannelSnapshot, + ); + const parentChannelId = list.channels.find( + (item) => item.id === channelId, + )?.parentChannelId; + const agentChoices = inviteAgents || !!sessionConversation; + const editingDisabled = disabled || admitting || !!submission?.locked; + const label = + customLabel ?? + (threadRootId ? "Reply to thread" : `Message #${channelName}`); const [value, updateDraft] = useState(() => - mentionDraft(readView(scope, draftKey, "")), + mentionDraft( + readView(scope, draftKey, submission?.initialDraft ?? ""), + ), ); const draft = value.text; const valueRef = useRef(value); @@ -139,7 +195,7 @@ function Composer({ const edit = useRef(undefined); const completion = useCompletionEditor( input, - !disabled && !!outbox?.supports(9), + !editingDisabled && !!outbox?.supports(9), ); useEffect(() => { const element = input.current; @@ -174,7 +230,11 @@ function Composer({ caret.current = undefined; }); function undo(redo: boolean) { - if (disabled || input.current?.readOnly || completion.composing.current) + if ( + editingDisabled || + input.current?.readOnly || + completion.composing.current + ) return; const source = redo ? history.current.future : history.current.past; const destination = redo ? history.current.past : history.current.future; @@ -198,7 +258,7 @@ function Composer({ range?: CompletionQuery, ) { if ( - disabled || + editingDisabled || !outbox?.supports(9) || !input.current?.isConnected || // DOM props are committed before child layout effects; closures can still @@ -267,28 +327,62 @@ function Composer({ ) ); } - function send() { + const currentAdmission = () => + live.current && + permitted.current && + session.channels.list().channels.find((item) => item.id === channelId) + ?.parentChannelId === parentChannelId; + async function prepareRecipients(recipients: readonly string[]) { + const channel = await session.workSessions.refreshMembership(channelId); + if (!currentAdmission()) + throw new Error("The session changed. Review its channel and retry."); + const missing = recipients.filter((key) => !channel.members?.includes(key)); + if (missing.length) { + await session.agentLibrary.refresh(); + if (!currentAdmission()) + throw new Error("The session changed. Review its channel and retry."); + await session.workSessions.addAgents( + channelId, + missing, + currentAdmission, + ); + if (!currentAdmission()) + throw new Error("The session changed. Review its channel and retry."); + } + } + function selectAgent(key: string) { + if (disabled || admission.current) return; + setSelectedAgent(key); + setError(undefined); + } + async function send() { if ( disabled || - input.current?.readOnly || - input.current?.disabled || + admission.current || + submission?.disabled || + (!submission && (input.current?.readOnly || input.current?.disabled)) || !draft.trim() || !outbox ) return; try { + if (submission) { + submission.submit(valueRef.current); + return; + } + const recipients = value.recipients.length + ? value.recipients.map((item) => item.pubkey) + : selectedAgent + ? [selectedAgent] + : []; + if (sessionConversation) { + admission.current = true; + setAdmitting(true); + await prepareRecipients(recipients); + } const id = threadRootId - ? session.messages.reply( - channelId, - threadRootId, - draft, - value.recipients.map((item) => item.pubkey), - ) - : session.messages.send( - channelId, - draft, - value.recipients.map((item) => item.pubkey), - ); + ? session.messages.reply(channelId, threadRootId, draft, recipients) + : session.messages.send(channelId, draft, recipients); onSend?.(id); completion.invalidate(); setDraft(""); @@ -296,7 +390,11 @@ function Composer({ input.current?.focus(); setError(undefined); } catch (reason) { - setError(reason instanceof Error ? reason.message : String(reason)); + if (live.current) + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + admission.current = false; + if (live.current) setAdmitting(false); } } if (!outbox?.supports(9)) @@ -321,7 +419,7 @@ function Composer({ send(); }} > - {!disabled && ( + {!disabled && !submission && ( )} @@ -347,7 +446,7 @@ function Composer({ saveDraft({ ...value, @@ -475,22 +575,36 @@ function Composer({ scope={scope} channelId={channelId} threadRootId={threadRootId} - disabled={disabled} + disabled={editingDisabled} + inviteAgents={agentChoices} insertText={(text) => insert(text)} insertMention={insertMention} focus={() => input.current?.focus()} /> )}
- - Shift + Enter for a new line - + {trailingTool ?? + (sessionConversation ? ( + + ) : ( + + Shift + Enter for a new line + + ))} @@ -499,7 +613,7 @@ function Composer({ {error && session.emoji?.snapshot().status === "error" && (
) : view ? ( {snapshot.root && ( | undefined, + agents: AgentLibrary["identities"] = [], ): Part[] { const text = row.content; - if ( - row.edited || - row.attachmentContentRemoved || - !profiles || - !row.mentions.length - ) + if (row.edited || row.attachmentContentRemoved || !row.mentions.length) return [{ text }]; const names = new Map>(); for (const id of new Set(row.mentions)) { - const name = profiles.get(id)?.name; const target = profileTarget(id); - if (!name || !target || /[\r\n]/.test(name)) continue; - const keys = names.get(name) ?? new Set(); - keys.add(target); - names.set(name, keys); + if (!target) continue; + const labels = [ + profiles?.get(id)?.name, + ...agents + .filter((agent) => agent.pubkey === id) + .map((agent) => agent.name), + ]; + for (const name of labels) { + if (!name || /[\r\n]/.test(name)) continue; + const keys = names.get(name) ?? new Set(); + keys.add(target); + names.set(name, keys); + } } // Ambiguous long names must still consume their span, never fall back to a prefix. const candidates = [...names].sort(([a], [b]) => b.length - a.length); diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index 7477f82c..56622f1a 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -8,7 +8,11 @@ export type ChannelSummary = Readonly<{ /** Members-only channel omitted from directories (NIP-29 `hidden`), such as a DM. */ hidden?: true; /** Relay-authored metadata; absent while metadata is unavailable. */ - channelType?: "stream" | "forum" | "dm"; + channelType?: "stream" | "forum" | "dm" | "session"; + /** Presentation-only parent from signed channel metadata; never grants access. */ + parentChannelId?: string | undefined; + /** Metadata update time used for stable work-history ordering. */ + updatedAt?: number; archived?: true; /** Exact members from the relay-signed roster; absent means unknown. */ members?: readonly string[]; @@ -16,6 +20,8 @@ export type ChannelSummary = Readonly<{ participants?: readonly string[]; }>; export type Profile = Readonly<{ + /** Agent identity advertised by signed kind-0 NIP-OA metadata; not ownership authority. */ + isAgent?: true; name: string; picture?: string; about?: string; diff --git a/src/features/relay/discovery.ts b/src/features/relay/discovery.ts index dc487296..3c227743 100644 --- a/src/features/relay/discovery.ts +++ b/src/features/relay/discovery.ts @@ -1,3 +1,4 @@ +import { sessionMetadata } from "../sessions/metadata"; import { objectBody } from "./body"; import { newer, hasTag, tag, type RelayEvent } from "./events"; import type { ChannelSummary } from "./contracts"; @@ -95,17 +96,31 @@ export class DiscoveryState { const event = this.metadata.get(id); return !!event && event.tags.some((entry) => entry[0] === "hidden"); } + isSession(id: string): boolean { + const event = this.metadata.get(id); + return ( + !!event && + tag(event, "t") === "stream" && + event.tags.some(([name]) => name === "private") && + sessionMetadata(tag(event, "about")) !== undefined + ); + } channels(): ChannelSummary[] { return [...this.rosters.keys()] .filter((id) => this.authorized(id)) .map((id): ChannelSummary => { const event = this.metadata.get(id); - const type = event && tag(event, "t"); + const type = this.isSession(id) ? "session" : event && tag(event, "t"); const channelType = - type === "stream" || type === "forum" || type === "dm" + type === "stream" || + type === "forum" || + type === "dm" || + (type === "session" && this.isSession(id)) ? type : undefined; const roster = this.rosters.get(id); + const parentId = + event && sessionMetadata(tag(event, "about"))?.parentId; return { id, name: this.name(id), @@ -122,6 +137,16 @@ export class DiscoveryState { ), ...(this.hidden(id) ? { hidden: true } : {}), ...(channelType ? { channelType } : {}), + ...(channelType === "session" && event + ? { + updatedAt: event.created_at, + ...(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( + parentId ?? "", + ) + ? { parentChannelId: parentId } + : {}), + } + : {}), ...(event && hasTag(event, "archived", "true") ? { archived: true } : {}), diff --git a/src/features/relay/mentions.test.ts b/src/features/relay/mentions.test.ts index d4cecdd0..235af0a9 100644 --- a/src/features/relay/mentions.test.ts +++ b/src/features/relay/mentions.test.ts @@ -16,7 +16,7 @@ const viewer = keypair(), honey = keypair(), namesake = keypair(); const owners: ReturnType[] = []; -function setup() { +function setup(sessionMode = false) { const wire = scriptedTransport(viewer.pubkey, relay.pubkey); let release: (() => void) | undefined; let held = false; @@ -29,15 +29,26 @@ function setup() { return signed(viewer, template); }); let currentRoster: RelayEvent | undefined; + const profileEvents: RelayEvent[] = []; const owner = createRelaySession( { ...wire.transport, - query: (filters, ...args) => - filters[0]?.authors?.[0] === relay.pubkey && - filters[0]?.kinds?.[0] === 39002 && - filters[0]?.limit === 1 - ? Promise.resolve(currentRoster ? [currentRoster] : []) - : wire.transport.query(filters, ...args), + query: (filters, ...args) => { + if ( + filters[0]?.authors?.[0] === relay.pubkey && + filters[0]?.kinds?.[0] === 39002 && + filters[0]?.limit === 1 + ) + return Promise.resolve(currentRoster ? [currentRoster] : []); + if ( + sessionMode && + filters.every((filter) => filter.kinds?.every((kind) => kind === 0)) + ) + return Promise.resolve(profileEvents); + if (sessionMode && filters.every((filter) => !!filter.ids)) + return Promise.resolve(publish.mock.calls.map(([event]) => event)); + return wire.transport.query(filters, ...args); + }, writer: { sign, publish }, }, { @@ -65,13 +76,37 @@ function setup() { pending = wire.next(); } currentRoster = roster(author, "c", keys, time); - pending.respond([currentRoster, metadata(relay, "c", "General")]); + pending.respond([ + currentRoster, + sessionMode + ? signed(relay, { + kind: 39000, + content: JSON.stringify({ name: "Work" }), + tags: [ + ["d", "c"], + ["t", "stream"], + ["private"], + ["about", "Buzz session (buzz.sessions/v1)"], + ], + }) + : metadata(relay, "c", "General"), + ]); await read; } return { ...wire, ...owner, members, + async agentProfile(key: typeof honey) { + profileEvents.push( + signed(key, { + kind: 0, + content: JSON.stringify({ name: "Honey" }), + tags: [["auth", viewer.pubkey, "", "a".repeat(128)]], + }), + ); + await owner.session.profiles.ensure([key.pubkey]); + }, sign, publish, hold: () => { @@ -192,3 +227,39 @@ it("disposal during signing fences publication to a retired session", async () = await flush(); expect(h.publish).not.toHaveBeenCalled(); }); + +it.each([false, true])( + "routes a sole session agent automatically and requires explicit choice after another joins, reply=%s", + async (reply) => { + const h = setup(true); + await h.members([viewer.pubkey, honey.pubkey]); + await h.agentProfile(honey); + const send = (mentions: readonly string[] = []) => + reply + ? h.session.messages.reply("c", "a".repeat(64), "Keep going", mentions) + : h.session.messages.send("c", "Keep going", mentions); + send(); + await flush(); + expect( + h.publish.mock.calls[0]?.[0].tags.filter(([name]) => name === "p"), + ).toEqual([["p", honey.pubkey]]); + + await h.members([viewer.pubkey, honey.pubkey, namesake.pubkey], 1700000001); + expect(() => send()).toThrow(/participants are still loading/); + await h.agentProfile(namesake); + expect(() => send()).toThrow(/multiple agents/); + send([namesake.pubkey]); + await flush(); + expect( + h.publish.mock.calls.at(-1)?.[0].tags.filter(([name]) => name === "p"), + ).toEqual([["p", namesake.pubkey]]); + expect(h.publish).toHaveBeenCalledTimes(2); + + await h.members([viewer.pubkey, namesake.pubkey], 1700000002); + send(); + await flush(); + expect( + h.publish.mock.calls.at(-1)?.[0].tags.filter(([name]) => name === "p"), + ).toEqual([["p", namesake.pubkey]]); + }, +); diff --git a/src/features/relay/message-projection.ts b/src/features/relay/message-projection.ts index daf42791..3e1237ea 100644 --- a/src/features/relay/message-projection.ts +++ b/src/features/relay/message-projection.ts @@ -19,6 +19,7 @@ export class MessageProjection { private channelId: string, private relayAuthor: string, private profiling: RelayProfiler, + private includeReplies: () => boolean = () => false, ) {} snapshot() { return this.rows; @@ -81,12 +82,17 @@ export class MessageProjection { const event = next.get(id); const row = event && messageKind(event.kind) - ? foldMessages(this.channelId, this.relayAuthor, [ - event, - ...[...(this.overlays.get(id) ?? [])].flatMap( - (ref) => next.get(ref) ?? [], - ), - ])[0] + ? foldMessages( + this.channelId, + this.relayAuthor, + [ + event, + ...[...(this.overlays.get(id) ?? [])].flatMap( + (ref) => next.get(ref) ?? [], + ), + ], + { includeReplies: this.includeReplies() }, + )[0] : undefined; if (row) this.messages.set(id, this.withDelivery(row, deliveries.get(id))); diff --git a/src/features/relay/messages.ts b/src/features/relay/messages.ts index 49c664c2..84dd143a 100644 --- a/src/features/relay/messages.ts +++ b/src/features/relay/messages.ts @@ -9,6 +9,10 @@ export function createMessages( find: (id: string) => EventData | undefined, emojiTags: (content: string) => string[][], validateMentions: (channelId: string, pubkeys: readonly string[]) => void, + resolveRecipients: ( + channelId: string, + pubkeys: readonly string[], + ) => readonly string[], ) { const writer = (kind: number) => { if (!outbox?.supports(kind)) @@ -26,7 +30,7 @@ export function createMessages( pubkeys.some((key) => !/^[0-9a-f]{64}$/.test(key)) ) throw new Error("Choose at most 32 valid mention recipients"); - const unique = [...new Set(pubkeys)]; + const unique = [...new Set(resolveRecipients(channelId, pubkeys))]; validateMentions(channelId, unique); return unique.map((key) => ["p", key]); }; diff --git a/src/features/relay/profile-details.test.ts b/src/features/relay/profile-details.test.ts index d1919044..87ade119 100644 --- a/src/features/relay/profile-details.test.ts +++ b/src/features/relay/profile-details.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "vitest"; import { foldProfiles } from "./profiles"; import { createProfileDirectory } from "./profile-directory"; import { createRelayReader } from "./reader"; -import { keypair, profile, scriptedTransport } from "./testing"; +import { keypair, profile, scriptedTransport, signed } from "./testing"; const user = keypair(); it("projects about safely and publishes an about-only replacement/removal", () => { const wire = scriptedTransport(user.pubkey, keypair().pubkey); @@ -29,3 +29,30 @@ it("projects about safely and publishes an about-only replacement/removal", () = reader.dispose(); } }); + +it("publishes an agent-marker-only profile change and its removal", () => { + const wire = scriptedTransport(user.pubkey, keypair().pubkey); + const reader = createRelayReader(wire.transport); + const directory = createProfileDirectory(reader.reader); + try { + directory.accept([profile(user, { name: "Mic" }, 1)]); + const before = directory.queries.snapshot().get(user.pubkey); + directory.accept([ + signed(user, { + kind: 0, + content: JSON.stringify({ name: "Mic" }), + created_at: 2, + tags: [["auth", "a".repeat(64), "", "b".repeat(128)]], + }), + ]); + expect(directory.queries.snapshot().get(user.pubkey)?.isAgent).toBe(true); + expect(directory.queries.snapshot().get(user.pubkey)).not.toBe(before); + directory.accept([profile(user, { name: "Mic" }, 3)]); + expect( + directory.queries.snapshot().get(user.pubkey)?.isAgent, + ).toBeUndefined(); + } finally { + directory.dispose(); + reader.dispose(); + } +}); diff --git a/src/features/relay/profile-directory.ts b/src/features/relay/profile-directory.ts index a3c895ee..731de677 100644 --- a/src/features/relay/profile-directory.ts +++ b/src/features/relay/profile-directory.ts @@ -54,7 +54,8 @@ export function createProfileDirectory( if ( old?.name === value.name && old?.picture === value.picture && - old?.about === value.about + old?.about === value.about && + old?.isAgent === value.isAgent ) next.set(id, old); } diff --git a/src/features/relay/profiles.ts b/src/features/relay/profiles.ts index 85909795..8282a558 100644 --- a/src/features/relay/profiles.ts +++ b/src/features/relay/profiles.ts @@ -14,6 +14,15 @@ export function foldProfiles( } const profiles = new Map(); for (const [pubkey, event] of latest) { + const agent = event.tags.some( + (tag) => + tag.length === 4 && + tag[0] === "auth" && + /^[0-9a-f]{64}$/.test(tag[1] ?? "") && + /^[0-9a-f]{128}$/.test(tag[3] ?? ""), + ) + ? { isAgent: true as const } + : {}; try { const body = JSON.parse(event.content) as { display_name?: unknown; @@ -33,6 +42,7 @@ export function foldProfiles( pubkey, Object.freeze({ name: name ?? pubkey.slice(0, 10), + ...agent, ...(picture ? { picture } : {}), ...(typeof body.about === "string" && body.about.trim() ? { about: body.about.trim() } @@ -40,7 +50,10 @@ export function foldProfiles( }), ); } catch { - profiles.set(pubkey, Object.freeze({ name: pubkey.slice(0, 10) })); + profiles.set( + pubkey, + Object.freeze({ name: pubkey.slice(0, 10), ...agent }), + ); } } return profiles; diff --git a/src/features/relay/session-agent-admission.test.ts b/src/features/relay/session-agent-admission.test.ts new file mode 100644 index 00000000..39c76473 --- /dev/null +++ b/src/features/relay/session-agent-admission.test.ts @@ -0,0 +1,349 @@ +import { expect, it, vi } from "vitest"; +import { createRelaySession } from "./session"; +import { PublishRejected } from "./outbox"; +import { keypair, roster, signed } from "./testing"; +import { matchesEvent } from "./projection"; +import type { RelayEvent } from "./events"; + +function setup() { + const viewer = keypair(), + relay = keypair(), + agent = keypair(); + const parent = "11111111-1111-4111-8111-111111111111", + child = "22222222-2222-4222-8222-222222222222", + standalone = "33333333-3333-4333-8333-333333333333"; + let members = [viewer.pubkey]; + let childMembers = [viewer.pubkey]; + let denyChild = false; + let clock = 1700000000; + let denied = false; + let foreignRoster = false; + let libraryFailure = false; + let afterPublish = () => {}; + const secondAgent = keypair().pubkey; + const meta = (id: string, type: string, extra: string[][] = []) => + signed(relay, { + kind: 39000, + content: "", + tags: [["d", id], ["t", type], ["name", id], ...extra], + }); + const publish = vi.fn(async (_event: RelayEvent) => { + const target = _event.tags.find(([name]) => name === "h")?.[1]; + if (denied || (denyChild && target === child)) + throw new PublishRejected("Only channel admins can add agents"); + if (target === parent) members = [viewer.pubkey, agent.pubkey]; + if (target === child) childMembers = [viewer.pubkey, agent.pubkey]; + clock++; + afterPublish(); + }); + const owner = createRelaySession( + { + viewer: viewer.pubkey, + relayAuthor: relay.pubkey, + media: () => undefined, + readAgentLibrary: async () => { + if (libraryFailure) throw new Error("Fixture library unavailable"); + return { + definitions: [], + identities: [ + { pubkey: agent.pubkey, name: "Outside agent" }, + { pubkey: secondAgent, name: "Second agent" }, + ], + }; + }, + writer: { + kinds: [9, 9000, 9007], + sign: async (template) => signed(viewer, template), + publish, + }, + query: async (filters) => { + if (foreignRoster && filters.some((filter) => filter["#d"])) + return [ + roster(agent, child, [viewer.pubkey, agent.pubkey], clock + 1), + ]; + const events = [ + meta(parent, "stream"), + meta(child, "stream", [ + ["private"], + ["about", `Buzz session (buzz.sessions/v1)\nparent:${parent}`], + ]), + meta(standalone, "stream", [ + ["private"], + ["about", "Buzz session (buzz.sessions/v1)"], + ]), + roster(relay, parent, members, clock), + roster(relay, child, childMembers, clock), + roster(relay, standalone, [viewer.pubkey], clock), + ]; + return events.filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ); + }, + }, + { outboxStorage: { load: () => [], save: () => {} } }, + ); + return { + owner, + parent, + child, + standalone, + agent: agent.pubkey, + secondAgent, + setMembership: (parentMembers: boolean, sessionMembers: boolean) => { + clock++; + members = [viewer.pubkey, ...(parentMembers ? [agent.pubkey] : [])]; + childMembers = [viewer.pubkey, ...(sessionMembers ? [agent.pubkey] : [])]; + }, + returnForeignRoster: () => { + foreignRoster = true; + }, + failLibraryRefresh: () => { + libraryFailure = true; + }, + afterPublish: (callback: () => void) => { + afterPublish = callback; + }, + publish, + denyChild: (value: boolean) => { + denyChild = value; + }, + setDenied: (value: boolean) => { + denied = value; + }, + async ready() { + owner.session.channels.ensureList(); + await vi.waitFor(() => + expect( + owner.session.channels + .list() + .channels.find((item) => item.id === child)?.parentChannelId, + ).toBe(parent), + ); + await owner.session.agentLibrary.refresh(); + }, + }; +} + +it("adds an outside agent once to each channel and confirms both memberships", async () => { + const test = setup(); + try { + await test.ready(); + await test.owner.session.workSessions.addAgents(test.child, [ + test.agent, + test.agent, + ]); + expect(test.publish).toHaveBeenCalledTimes(2); + expect(test.publish.mock.calls[0]?.[0]).toMatchObject({ + kind: 9000, + tags: expect.arrayContaining([ + ["h", test.parent], + ["p", test.agent], + ]), + }); + expect( + test.publish.mock.calls[0]?.[0].tags.some(([name]) => name === "role"), + ).toBe(false); + expect( + test.owner.session.channels + .list() + .channels.find((item) => item.id === test.child)?.members, + ).toContain(test.agent); + await test.owner.session.workSessions.addAgents(test.parent, [test.agent]); + expect(test.publish).toHaveBeenCalledTimes(2); + } finally { + test.owner.dispose(); + } +}); +it("keeps channel permission failures and retries the same saved invitation", async () => { + const test = setup(); + try { + await test.ready(); + test.setDenied(true); + await expect( + test.owner.session.workSessions.addAgents(test.child, [test.agent]), + ).rejects.toThrow(/Only channel admins/); + expect( + test.owner.session.channels + .list() + .channels.find((item) => item.id === test.child)?.members, + ).not.toContain(test.agent); + const first = test.publish.mock.calls[0]?.[0].id; + test.setDenied(false); + await test.owner.session.workSessions.addAgents(test.child, [test.agent]); + expect(test.publish.mock.calls[1]?.[0].id).toBe(first); + expect(test.publish).toHaveBeenCalledTimes(3); + } finally { + test.owner.dispose(); + } +}); +it("rejects nonmember identities outside the agent library before adding anyone", async () => { + const test = setup(); + try { + await test.ready(); + await expect( + test.owner.session.workSessions.addAgents(test.parent, [ + test.agent, + "f".repeat(64), + ]), + ).rejects.toThrow(/agent library/); + expect(test.publish).not.toHaveBeenCalled(); + } finally { + test.owner.dispose(); + } +}); + +it("rejects a stale cached identity after the current library refresh fails", async () => { + const test = setup(); + try { + await test.ready(); + test.failLibraryRefresh(); + await test.owner.session.agentLibrary.refresh(); + const library = test.owner.session.agentLibrary.snapshot(); + expect(library.status).toBe("error"); + expect(library.identities.map((identity) => identity.pubkey)).toContain( + test.agent, + ); + await expect( + test.owner.session.workSessions.addAgents(test.parent, [test.agent]), + ).rejects.toThrow(/agent library/); + expect(test.publish).not.toHaveBeenCalled(); + } finally { + test.owner.dispose(); + } +}); + +it("rejects a stale cached identity from a standalone session picker", async () => { + const test = setup(); + try { + await test.ready(); + test.failLibraryRefresh(); + await test.owner.session.agentLibrary.refresh(); + expect(() => + test.owner.session.workSessions.invite(test.standalone, test.agent), + ).toThrow(/agent library/); + expect(test.publish).not.toHaveBeenCalled(); + } finally { + test.owner.dispose(); + } +}); + +it("allows a freshly verified parent member when the local library is unavailable", async () => { + const test = setup(); + try { + await test.ready(); + test.setMembership(true, false); + test.failLibraryRefresh(); + await test.owner.session.agentLibrary.refresh(); + await test.owner.session.workSessions.addAgents(test.child, [test.agent]); + expect(test.publish).toHaveBeenCalledOnce(); + expect(test.publish.mock.calls[0]?.[0].tags).toContainEqual([ + "h", + test.child, + ]); + } finally { + test.owner.dispose(); + } +}); + +it("stops additional invitations when the composing view closes during admission", async () => { + const test = setup(); + let active = true; + try { + await test.ready(); + test.afterPublish(() => { + active = false; + }); + await expect( + test.owner.session.workSessions.addAgents( + test.child, + [test.agent, test.secondAgent], + () => active, + ), + ).rejects.toThrow(/cancelled/); + expect(test.publish).toHaveBeenCalledOnce(); + expect(test.publish.mock.calls[0]?.[0].tags).toContainEqual([ + "p", + test.agent, + ]); + } finally { + test.owner.dispose(); + } +}); + +it.each([false, true])( + "ordinary child admission uses real separate rosters and recovers denial: %s", + async (rejectChild) => { + const test = setup(); + try { + await test.ready(); + test.denyChild(rejectChild); + if (rejectChild) { + await expect( + test.owner.session.workSessions.addAgents(test.child, [test.agent]), + ).rejects.toThrow(/Only channel admins/); + expect( + test.owner.session.channels + .list() + .channels.find((item) => item.id === test.child)?.members, + ).not.toContain(test.agent); + test.denyChild(false); + } + await test.owner.session.workSessions.addAgents(test.child, [test.agent]); + expect( + test.publish.mock.calls.map( + ([event]) => event.tags.find(([name]) => name === "h")?.[1], + ), + ).toEqual( + rejectChild + ? [test.parent, test.child, test.child] + : [test.parent, test.child], + ); + if (rejectChild) + expect(test.publish.mock.calls[1]?.[0].id).toBe( + test.publish.mock.calls[2]?.[0].id, + ); + expect( + test.owner.session.channels + .list() + .channels.find((item) => item.id === test.child)?.members, + ).toContain(test.agent); + await test.owner.session.workSessions.addAgents(test.child, [test.agent]); + expect(test.publish).toHaveBeenCalledTimes(rejectChild ? 3 : 2); + } finally { + test.owner.dispose(); + } + }, +); + +it("refreshes stale membership and preserves an existing child's independent access", async () => { + const test = setup(); + try { + await test.ready(); + test.setMembership(false, true); + await test.owner.session.workSessions.addAgents(test.child, [test.agent]); + expect(test.publish).not.toHaveBeenCalled(); + expect( + test.owner.session.channels + .list() + .channels.find((item) => item.id === test.child)?.members, + ).toContain(test.agent); + test.setMembership(false, false); + await test.owner.session.workSessions.addAgents(test.child, [test.agent]); + expect(test.publish).toHaveBeenCalledTimes(2); + } finally { + test.owner.dispose(); + } +}); +it("does not accept a foreign-signed roster as fresh membership", async () => { + const test = setup(); + try { + await test.ready(); + test.returnForeignRoster(); + await expect( + test.owner.session.workSessions.addAgents(test.child, [test.agent]), + ).rejects.toThrow(/refresh channel membership/); + expect(test.publish).not.toHaveBeenCalled(); + } finally { + test.owner.dispose(); + } +}); diff --git a/src/features/relay/session-discovery.test.ts b/src/features/relay/session-discovery.test.ts new file mode 100644 index 00000000..666e444a --- /dev/null +++ b/src/features/relay/session-discovery.test.ts @@ -0,0 +1,76 @@ +import { expect, it } from "vitest"; +import { DiscoveryState } from "./discovery"; +import { keypair, roster, signed } from "./testing"; +import { SESSION_CHANNEL_DESCRIPTION } from "../sessions/metadata"; +it("recognizes only relay-authorized private channel session metadata and keeps ordinary thread reads", () => { + const viewer = keypair(), + relay = keypair(); + const discovery = new DiscoveryState(viewer.pubkey, relay.pubkey); + discovery.accept(roster(relay, "work", [viewer.pubkey])); + const metadata = ( + author: typeof relay, + privateChannel: boolean, + time: number, + ) => + signed(author, { + kind: 39000, + created_at: time, + content: "", + tags: [ + ["d", "work"], + ["name", "Work"], + ["t", "stream"], + ["about", SESSION_CHANNEL_DESCRIPTION], + ...(privateChannel ? [["private"]] : []), + ], + }); + discovery.accept(metadata(viewer, true, 1)); + expect(discovery.isSession("work")).toBe(false); + discovery.accept(metadata(relay, false, 1)); + expect(discovery.isSession("work")).toBe(false); + discovery.accept(metadata(relay, true, 2)); + expect(discovery.channels()[0]?.channelType).toBe("session"); + discovery.accept( + signed(relay, { + kind: 39000, + created_at: 3, + content: "", + tags: [ + ["d", "work"], + ["t", "session"], + ], + }), + ); + expect(discovery.isSession("work")).toBe(false); +}); + +it("restores an ordinary child from signed metadata without inheriting parent access", () => { + const viewer = keypair(), + relay = keypair(); + const parent = "11111111-1111-4111-8111-111111111111"; + const child = "22222222-2222-4222-8222-222222222222"; + const discovery = new DiscoveryState(viewer.pubkey, relay.pubkey); + discovery.accept(roster(relay, parent, [viewer.pubkey])); + discovery.accept( + signed(relay, { + kind: 39000, + content: "", + tags: [ + ["d", child], + ["t", "stream"], + ["private"], + ["name", "Work"], + ["about", `${SESSION_CHANNEL_DESCRIPTION}\nparent:${parent}`], + ], + }), + ); + expect(discovery.authorized(child)).toBe(false); + discovery.accept(roster(relay, child, [viewer.pubkey])); + expect(discovery.channels().find((item) => item.id === child)).toMatchObject({ + channelType: "session", + parentChannelId: parent, + }); + discovery.accept(roster(relay, parent, [], 1800000000)); + expect(discovery.authorized(parent)).toBe(false); + expect(discovery.authorized(child)).toBe(true); +}); diff --git a/src/features/relay/session-window.test.ts b/src/features/relay/session-window.test.ts new file mode 100644 index 00000000..5bee08d4 --- /dev/null +++ b/src/features/relay/session-window.test.ts @@ -0,0 +1,81 @@ +import { assert, expect, it, vi } from "vitest"; +import { readSessionWindow } from "./session-window"; +import { foldMessages } from "./fold"; +import { keypair, message, signed } from "./testing"; +import type { RelayEvent } from "./events"; + +const author = keypair(); +const root = message(author, "work", "Prompt", 10); +const reply = message(author, "work", "Answer", 11, [ + ["e", root.id, "", "reply"], +]); + +it("reads threaded replies inline and closes edits and deletions before publishing", async () => { + const edit = signed(author, { + kind: 40003, + content: "Edited", + created_at: 12, + tags: [["e", reply.id]], + }); + const deletion = signed(author, { + kind: 5, + content: "", + created_at: 13, + tags: [["e", edit.id]], + }); + const read = vi + .fn<(...args: unknown[]) => Promise>() + .mockResolvedValueOnce([reply, root]) + .mockResolvedValueOnce([edit]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([deletion]) + .mockResolvedValueOnce([]); + const page = await readSessionWindow({ read }, "work", null, {}); + expect( + foldMessages("work", author.pubkey, page.events, { + includeReplies: true, + }).map((row) => row.content), + ).toEqual(["Prompt", "Answer"]); + expect(page.cursor).toEqual({ createdAt: root.created_at, eventId: root.id }); + expect(page.hasMore).toBe(true); + expect(read.mock.calls[0]?.[0]).toEqual([ + { kinds: [9, 40002, 40099], "#h": ["work"], limit: 20 }, + ]); +}); + +it("keeps equal-time paging deterministic and rejects a repeated cursor", async () => { + const sameTime = [ + message(author, "work", "A", 20), + message(author, "work", "B", 20), + ].sort((a, b) => a.id.localeCompare(b.id)); + const [first, second] = sameTime; + assert(first && second); + const cursor = { createdAt: 20, eventId: first.id }; + const read = vi + .fn() + .mockResolvedValueOnce([second]) + .mockResolvedValueOnce([]); + expect( + (await readSessionWindow({ read }, "work", cursor, {})).cursor?.eventId, + ).toBe(second.id); + expect(read.mock.calls[0]?.[0][0]).toMatchObject({ + until: 20, + before_id: first.id, + }); + await expect( + readSessionWindow({ read: async () => [first] }, "work", cursor, {}), + ).rejects.toThrow(/did not advance/); + expect( + await readSessionWindow({ read: async () => [] }, "work", cursor, {}), + ).toMatchObject({ events: [], hasMore: false, cursor: null }); +}); + +it("propagates failed overlay reads instead of showing incomplete message state", async () => { + const read = vi + .fn() + .mockResolvedValueOnce([reply]) + .mockRejectedValueOnce(new Error("offline")); + await expect(readSessionWindow({ read }, "work", null, {})).rejects.toThrow( + "offline", + ); +}); diff --git a/src/features/relay/session-window.ts b/src/features/relay/session-window.ts new file mode 100644 index 00000000..c2d76c31 --- /dev/null +++ b/src/features/relay/session-window.ts @@ -0,0 +1,92 @@ +import { byteSize } from "./budget"; +import { hasTag, type ReadFilter, type RelayEvent } from "./events"; +import { CHANNEL_ROW_KINDS } from "./membership"; +import type { ReadOptions, RelayReader } from "./reader"; +import { WINDOW_PAGE_SIZE, type WindowCursor, type WindowPage } from "./window"; + +const compare = (a: RelayEvent, b: RelayEvent) => + b.created_at - a.created_at || a.id.localeCompare(b.id); +const cursorOf = (event: RelayEvent): WindowCursor => ({ + createdAt: event.created_at, + eventId: event.id, +}); +function after(event: RelayEvent, cursor: WindowCursor) { + return ( + event.created_at < cursor.createdAt || + (event.created_at === cursor.createdAt && event.id > cursor.eventId) + ); +} +function paging(cursor: WindowCursor | null) { + return cursor ? { until: cursor.createdAt, before_id: cursor.eventId } : {}; +} + +/** Flat conversation paging for ordinary private channels used as sessions. + * General queries have composite paging but no signed window bounds. Keep that + * distinction explicit, and fetch complete overlays before displaying a page. */ +export async function readSessionWindow( + reader: RelayReader, + channelId: string, + cursor: WindowCursor | null, + options: ReadOptions, +): Promise { + const response = await reader.read( + [ + { + kinds: [...CHANNEL_ROW_KINDS], + "#h": [channelId], + limit: WINDOW_PAGE_SIZE, + ...paging(cursor), + }, + ], + options, + ); + const rows = [...new Map(response.map((event) => [event.id, event])).values()] + .filter( + (event) => + CHANNEL_ROW_KINDS.includes(event.kind) && hasTag(event, "h", channelId), + ) + .sort(compare); + if (rows.some((event) => cursor && !after(event, cursor))) + throw new Error("Session history did not advance. Retry messages."); + const events = [...rows]; + async function overlays(kinds: number[], ids: string[]) { + if (!ids.length) return []; + let next: WindowCursor | null = null; + const found: RelayEvent[] = []; + for (;;) { + const filter: ReadFilter = { + kinds, + "#e": ids, + limit: 500, + ...paging(next), + }; + const page = [...(await reader.read([filter], options))].sort(compare); + const last = page.at(-1); + if (!last) return found; + if (page.some((event) => next && !after(event, next))) + throw new Error( + "Session message updates did not advance. Retry messages.", + ); + found.push(...page); + events.push(...page); + if (events.length > 2000 || byteSize(events) > 4 * 1024 * 1024) + throw new Error("Session message updates exceed the read budget."); + next = cursorOf(last); + // Continue through short pages: visibility filtering can shrink a response. + } + } + const aux = await overlays( + [5, 7, 9005, 40003], + rows.map((event) => event.id), + ); + await overlays( + [5, 9005], + aux + .filter((event) => [7, 40003].includes(event.kind)) + .map((event) => event.id), + ); + const last = rows.at(-1); + // General query pages can be filtered by authorization. Keep offering older + // history until an empty page, rather than treating a short page as proof. + return { events, cursor: last ? cursorOf(last) : null, hasMore: !!last }; +} diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index e529ca20..c676a014 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -8,6 +8,7 @@ import { } from "./reader"; import { createAgentActivity } from "../agents/activity"; import { OBSERVER_KIND } from "../agents/observer"; +import { createWorkSessions } from "./work-sessions"; import { createAgentLibrary } from "../agents/library"; import { createIdentityArchives } from "./identity-archives"; import { @@ -44,6 +45,7 @@ import { createOutbox, type OutboxStorage, } from "./outbox"; +import { sessionRecipients } from "../sessions/recipients"; import { createMessages } from "./messages"; import { createThreadView } from "./threads"; import { ByteLru } from "./budget"; @@ -629,6 +631,7 @@ export function createRelaySession( notify, ); const session = Object.freeze({ + viewer: transport?.viewer, /** Verified new live-route messages, after reconciliation. Never history or local intent. */ subscribeIncoming(listener: IncomingListener) { if (closed) return () => {}; @@ -638,6 +641,35 @@ export function createRelaySession( }; }, typing: typing.capability, + workSessions: createWorkSessions( + writes?.outbox, + channels.queries, + verified, + lifetime.signal, + writes?.local, + async (id) => { + if (!transport) return false; + // Confirm only this viewer's exact creation receipt. Discovery may be + // incomplete; this never admits the channel or grants content access. + const events = await requests.reader.read( + [{ kinds: [9007], ids: [id], authors: [transport.viewer], limit: 1 }], + { signal: lifetime.signal, fresh: true }, + ); + return events.some( + (event) => + event.id === id && + event.kind === 9007 && + event.pubkey === transport.viewer, + ); + }, + () => { + const library = agentLibrary.queries.snapshot(); + return library.status === "ready" + ? library.identities.map((agent) => agent.pubkey) + : []; + }, + transport?.relayAuthor, + ), unread: unread.capability, sidebarPreferences: sidebarPreferences.queries, live, @@ -651,6 +683,16 @@ export function createRelaySession( retainedEvent(id), emoji.tags, validateMentions, + (channelId, explicit) => + sessionRecipients( + channels.queries + .list() + .channels.find((channel) => channel.id === channelId), + profiles.queries.snapshot(), + agentLibrary.queries.snapshot(), + transport?.viewer, + explicit, + ), ), /** An owned bounded thread reader. Dispose on close; the session retains access/lifetime authority. */ thread( diff --git a/src/features/relay/store.test.ts b/src/features/relay/store.test.ts index 4c2467dd..78a89c44 100644 --- a/src/features/relay/store.test.ts +++ b/src/features/relay/store.test.ts @@ -9,6 +9,7 @@ import { profile, roster, scriptedTransport, + signed, } from "./testing"; const relay = keypair(), @@ -209,3 +210,114 @@ describe("channel store", () => { }); }); }); + +it("keeps session replies in the main timeline and observes parent changes", async () => { + const { store, queries, next } = setup(); + const parent = "11111111-1111-4111-8111-111111111111"; + const sessionMetadata = (time: number, moved = false) => + signed(relay, { + kind: 39000, + content: "", + created_at: time, + tags: [ + ["d", "work"], + ["name", "Work topic"], + ["t", "stream"], + ["private"], + [ + "about", + `Buzz session (buzz.sessions/v1)${moved ? `\nparent:${parent}` : ""}`, + ], + ], + }); + queries.ensureList(); + next().respond([roster(relay, "work", [viewer.pubkey]), sessionMetadata(10)]); + await flush(); + expect(queries.list().channels[0]).toMatchObject({ + channelType: "session", + name: "Work topic", + }); + queries.ensure("work"); + const request = next(); + expect(request.filters[0]).not.toHaveProperty("top_level"); + const root = message(viewer, "work", "Prompt", 20); + const reply = message(alice, "work", "Reply", 21, [ + ["e", root.id, "", "root"], + ["e", root.id, "", "reply"], + ]); + request.respond([root, reply]); + await flush(); + next().respond([]); // Load message overlays before exposing the conversation. + await flush(); + expect(queries.window("work").rows.map((row) => row.content)).toEqual([ + "Prompt", + "Reply", + ]); + // Drain profile lookup, then refresh the same roster with only a parent change. + next().respond([]); + await flush(); + queries.refreshList?.(); + next().respond([ + roster(relay, "work", [viewer.pubkey]), + sessionMetadata(30, true), + ]); + await flush(); + expect(queries.list().channels[0]).toMatchObject({ + parentChannelId: parent, + updatedAt: 30, + }); + store.dispose(); +}); + +it("opens an ordinary private session with existing agent replies in its main timeline", async () => { + const { store, queries, next } = setup(); + queries.ensureList(); + next().respond([ + roster(relay, "work", [viewer.pubkey]), + signed(relay, { + kind: 39000, + content: "", + tags: [ + ["d", "work"], + ["name", "Work"], + ["t", "stream"], + ["private"], + ["about", "Buzz session (buzz.sessions/v1)"], + ], + }), + profile(alice, { name: "Alice" }), + profile(viewer, { name: "Viewer" }), + ]); + await flush(); + queries.ensure("work"); + const request = next(); + expect(request.filters[0]).not.toHaveProperty("top_level"); + expect(request.filters[0]).not.toHaveProperty("session_timeline"); + const root = message(viewer, "work", "Prompt", 20); + const reply = message(alice, "work", "Answer", 21, [ + ["e", root.id, "", "reply"], + ]); + request.respond([reply, root]); + await flush(); + expect(queries.window("work").rows).toEqual([]); + next().respond([]); // Fetch message overlays before presenting the page. + await flush(); + expect(queries.window("work").rows.map((row) => row.content)).toEqual([ + "Prompt", + "Answer", + ]); + expect(queries.window("work").hasMore).toBe(true); + next().respond([]); // Optional profile enrichment is independent of history. + await flush(); + queries.loadOlder("work"); + const older = next(); + expect(older.filters[0]).toMatchObject({ until: 20, before_id: root.id }); + older.respond([]); + await flush(); + expect(queries.window("work").hasMore).toBe(false); + expect(queries.window("work").rows.map((row) => row.content)).toEqual([ + "Prompt", + "Answer", + ]); + store.dispose(); +}); diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index e1e993c0..0007b854 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -12,9 +12,10 @@ import type { import { DiscoveryState } from "./discovery"; import { foldMessages } from "./fold"; import { eventDto, hasTag, tag, type RelayEvent } from "./events"; -import type { RelayReader, Priority } from "./reader"; +import type { RelayReader, ReadOptions, Priority } from "./reader"; import type { ProfileDirectory } from "./profile-directory"; import { parseWindow, windowFilter, type WindowCursor } from "./window"; +import { readSessionWindow } from "./session-window"; import { ByteLru, byteSize } from "./budget"; import type { HeadPersistence, SavedHead } from "./persistence"; import { createMediaPreparation, saveData } from "./media"; @@ -88,6 +89,14 @@ export function createChannelStore( directory: ProfileDirectory, options: ChannelStoreOptions = {}, ) { + const foldChannelMessages = ( + channelId: string, + author: string, + events: readonly import("./events").EventData[], + ) => + foldMessages(channelId, author, events, { + includeReplies: discovery?.isSession(channelId) ?? false, + }); const { maxWindows = 3, unavailableReason, @@ -166,6 +175,8 @@ export function createChannelStore( old.preview === preview && old.hidden === channel.hidden && old.channelType === channel.channelType && + old.parentChannelId === channel.parentChannelId && + old.updatedAt === channel.updatedAt && old.archived === channel.archived && old.members?.length === channel.members?.length && (old.members ?? []).every( @@ -299,6 +310,7 @@ export function createChannelStore( channelId, transport?.relayAuthor ?? "", profiling, + () => discovery?.isSession(channelId) ?? false, ), channelId, snapshot: idleWindow(channelId), @@ -367,6 +379,8 @@ export function createChannelStore( function save(channelId: string, head: Head, previousProfiles?: string) { if ( !persistence || + // Compatibility queries have no signed bounds to restore from disk. + isSession(channelId) || !authorized(channelId) || disposed || heads.peek(channelId) !== head @@ -420,6 +434,31 @@ export function createChannelStore( void persistence?.remove(channelId).catch(() => {}); }); } + const isSession = (channelId: string) => !!discovery?.isSession(channelId); + async function readPage( + channelId: string, + cursor: WindowCursor | null, + settings: ReadOptions, + ) { + if (!transport) throw new Error("Relay is unavailable"); + if (isSession(channelId)) { + const page = await readSessionWindow( + transport, + channelId, + cursor, + settings, + ); + return { events: page.events, page }; + } + const events = await transport.read( + [windowFilter(channelId, cursor)], + settings, + ); + return { + events, + page: parseWindow(channelId, cursor, transport.relayAuthor, events), + }; + } async function requestHead( channelId: string, priority: Priority, @@ -433,7 +472,7 @@ export function createChannelStore( try { if (disposed || !transport || !authorized(channelId)) throw new DOMException("Stale request", "AbortError"); - const events = await transport.read([windowFilter(channelId, null)], { + const { events, page } = await readPage(channelId, null, { signal: controller.signal, priority, }); @@ -446,10 +485,9 @@ export function createChannelStore( throw new DOMException("Stale request", "AbortError"); const retained = heads.peek(channelId); if (retained?.events === events) return retained; - const page = parseWindow(channelId, null, transport.relayAuthor, events); head = { rows: Object.freeze( - foldMessages(channelId, transport.relayAuthor, page.events), + foldChannelMessages(channelId, transport.relayAuthor, page.events), ), cursor: page.cursor, hasMore: page.hasMore, @@ -509,24 +547,17 @@ export function createChannelStore( setWindow(state, patchFromHead(head)); return; } - const events = await transport.read( - [windowFilter(state.channelId, cursor)], - { signal: controller.signal }, - ); + const { page } = await readPage(state.channelId, cursor, { + signal: controller.signal, + }); if (!live(state, generation)) return; - const page = parseWindow( - state.channelId, - cursor, - transport.relayAuthor, - events, - ); const combined = new Map( (cursor ? state.events : []).map((event) => [event.id, event]), ); for (const event of page.events) combined.set(event.id, event); const retained = [...combined.values()]; const rows = Object.freeze( - foldMessages(state.channelId, transport.relayAuthor, retained), + foldChannelMessages(state.channelId, transport.relayAuthor, retained), ); if ( rows.length > maxHistoryRows || @@ -596,6 +627,7 @@ export function createChannelStore( if (disposed || generation !== epoch) return; if ( !allowed?.has(record.channelId) || + isSession(record.channelId) || heads.peek(record.channelId) || !Number.isFinite(record.savedAt) || record.savedAt > now() || @@ -631,7 +663,11 @@ export function createChannelStore( accessibleEvents, ); const rows = Object.freeze( - foldMessages(record.channelId, transport.relayAuthor, page.events), + foldChannelMessages( + record.channelId, + transport.relayAuthor, + page.events, + ), ); const head: Head = { rows, @@ -1155,7 +1191,7 @@ export function createChannelStore( const preview = windows.has(channelId) ? tails.peek(channelId)?.preview : messagePreview( - foldMessages(channelId, transport.relayAuthor, [ + foldChannelMessages(channelId, transport.relayAuthor, [ ...new Map( [...(heads.peek(channelId)?.events ?? []), ...retained].map( (event) => [event.id, event], @@ -1260,7 +1296,9 @@ export function createChannelStore( heads.set(id, { ...head, events, - rows: Object.freeze(foldMessages(id, transport.relayAuthor, events)), + rows: Object.freeze( + foldChannelMessages(id, transport.relayAuthor, events), + ), }); } for (const [id, tail] of tails.entries()) { @@ -1272,7 +1310,7 @@ export function createChannelStore( tails.set(id, { events, preview: messagePreview( - foldMessages(id, transport.relayAuthor, [ + foldChannelMessages(id, transport.relayAuthor, [ ...(heads.peek(id)?.events ?? []), ...events, ]), diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index 42c9b321..297abb39 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -44,6 +44,7 @@ export interface ReadTransport { readonly workflows?: WorkflowHost; /** Purpose-bound observer decoding on the shared host live stream. */ readonly agentActivity?: boolean; + /** Explicit relay-advertised session command support. */ /** Host-projected local library; display only, never relay authority. */ readonly readAgentLibrary?: AgentLibraryReader; /** Host-only decoder of the viewer's two signed sidebar preference coordinates. */ diff --git a/src/features/relay/work-sessions.test.ts b/src/features/relay/work-sessions.test.ts new file mode 100644 index 00000000..d18d3330 --- /dev/null +++ b/src/features/relay/work-sessions.test.ts @@ -0,0 +1,304 @@ +import { expect, it, vi } from "vitest"; +import { SESSION_CHANNEL_DESCRIPTION } from "../sessions/metadata"; +import { createWorkSessions } from "./work-sessions"; +import type { Outbox, OutgoingEvent } from "./outbox"; +import type { ChannelQueries } from "./contracts"; +import { keypair, message, roster, signed } from "./testing"; +import { createRelaySession } from "./session"; +import { PublishRejected } from "./outbox"; +const event = message(keypair(), "session", "Work", 1); + +it.each([true, false])( + "confirms an exact own creation without admitting a channel missing its roster (receipt: %s)", + async (found) => { + const viewer = keypair(), + relay = keypair(); + const channelId = "11111111-1111-4111-8111-111111111111"; + const creation = signed(viewer, { + kind: 9007, + content: "", + tags: [["h", channelId]], + }); + const publish = vi.fn(async () => { + throw new PublishRejected("duplicate: channel already exists"); + }); + const query = vi.fn(async (filters: Parameters[0]) => + found && filters.some((filter) => filter.ids?.includes(creation.id)) + ? [creation] + : [], + ); + const owner = createRelaySession( + { + viewer: viewer.pubkey, + relayAuthor: relay.pubkey, + media: () => undefined, + query, + writer: { + kinds: [9, 9000, 9007], + sign: async (template) => signed(viewer, template), + publish, + }, + }, + { + outboxStorage: { + load: () => [ + { event: creation, signed: creation, delivery: "unknown" }, + ], + save: () => {}, + }, + }, + ); + try { + owner.session.channels.ensureList?.(); + await vi.waitFor(() => + expect(owner.session.channels.list().status).toBe("ready"), + ); + if (found) { + await owner.session.workSessions.delivered(creation.id); + expect(publish).not.toHaveBeenCalled(); + expect(query).toHaveBeenCalledWith( + [ + { + kinds: [9007], + ids: [creation.id], + authors: [viewer.pubkey], + limit: 1, + }, + ], + expect.anything(), + expect.anything(), + expect.anything(), + ); + } else { + await expect( + owner.session.workSessions.delivered(creation.id), + ).rejects.toThrow(/duplicate/); + } + expect(owner.session.channels.list().channels).toHaveLength(0); + expect(() => + owner.session.messages.send(channelId, "Work", [viewer.pubkey]), + ).toThrow(); + } finally { + owner.dispose(); + } + }, +); +type RelaySessionRead = ReturnType< + typeof createRelaySession +>["session"]["read"]; +function setup(channelCreation = true) { + let items: readonly OutgoingEvent[] = [{ event, delivery: "accepted" }]; + const listeners = new Set<() => void>(); + const receipts = { + snapshot: () => items, + subscribe: (fn: () => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + }; + const outbox: Outbox = { + snapshot: () => [], + subscribe: receipts.subscribe, + supports: (kind) => channelCreation && [9, 9000, 9007].includes(kind), + send: vi.fn(() => event.id), + retry: vi.fn(() => { + items = [{ event, delivery: "accepted" }]; + for (const fn of listeners) fn(); + }), + dismiss: vi.fn(async () => {}), + }; + const channels = { + list: () => ({ status: "ready", channels: [] }), + } as unknown as ChannelQueries; + const reader = { read: vi.fn(async () => [event]) }; + const controller = new AbortController(); + return { + service: createWorkSessions( + outbox, + channels, + reader, + controller.signal, + receipts, + ), + outbox, + reader, + listeners, + controller, + setItems: (next: readonly OutgoingEvent[]) => { + items = next; + }, + }; +} +it("confirms completed journal receipts after they leave the pending outbox", async () => { + const test = setup(); + await test.service.delivered(event.id); + expect(test.reader.read).not.toHaveBeenCalled(); + expect(test.listeners.size).toBe(0); +}); +it("retries the same unknown event and confirms restored receipts through verified reads", async () => { + const test = setup(); + test.setItems([{ event, delivery: "unknown" }]); + await test.service.delivered(event.id); + expect(test.outbox.retry).toHaveBeenCalledWith(event.id); + test.setItems([]); + await test.service.delivered(event.id); + expect(test.reader.read).toHaveBeenCalled(); +}); +it("fails closed when capability is absent or connection ends", async () => { + const unsupported = setup(false); + expect(unsupported.service.available).toBe(false); + expect(() => + unsupported.service.create("11111111-1111-4111-8111-111111111111", "Work"), + ).toThrow(/does not support/); + const test = setup(); + test.setItems([{ event, delivery: "sending" }]); + const pending = test.service.delivered(event.id); + test.controller.abort(); + await expect(pending).rejects.toThrow(/connection changed/); + expect(test.listeners.size).toBe(0); +}); + +it("permits editing only after a definitive rejection, never an uncertain send", async () => { + const test = setup(); + test.setItems([{ event, delivery: "unknown" }]); + expect(test.service.failed(event.id)).toBe(false); + await expect(test.service.discardFailed(event.id)).rejects.toThrow( + /unconfirmed/, + ); + expect(test.outbox.dismiss).not.toHaveBeenCalled(); + test.setItems([{ event, delivery: "failed" }]); + expect(test.service.failed(event.id)).toBe(true); + await test.service.discardFailed(event.id); + expect(test.outbox.dismiss).toHaveBeenCalledWith(event.id); +}); + +it("starts a standalone session using existing private-channel creation without the Sessions extension", () => { + const test = setup(); + const id = "11111111-1111-4111-8111-111111111111"; + expect(test.service.available).toBe(true); + test.service.create(id, "Work"); + expect(test.outbox.send).toHaveBeenCalledWith({ + kind: 9007, + content: "", + tags: [ + ["h", id], + ["name", "Work"], + ["visibility", "private"], + ["channel_type", "stream"], + ["about", "Buzz session (buzz.sessions/v1)"], + ], + }); + const parent = "22222222-2222-4222-8222-222222222222"; + test.service.create(id, "Child", parent); + expect(test.outbox.send).toHaveBeenLastCalledWith({ + kind: 9007, + content: "", + tags: [ + ["h", id], + ["name", "Child"], + ["visibility", "private"], + ["channel_type", "stream"], + ["about", `${SESSION_CHANNEL_DESCRIPTION}\nparent:${parent}`], + ], + }); + expect(() => test.service.create(id, "Child", id)).toThrow(/own parent/); + expect(test.outbox.send).toHaveBeenCalledTimes(2); +}); + +it("recovers a lost normal-channel creation acknowledgment only with its exact verified event", async () => { + const test = setup(); + const creation = signed(keypair(), { + kind: 9007, + content: "", + tags: [ + ["h", "11111111-1111-4111-8111-111111111111"], + ["name", "Work"], + ["visibility", "private"], + ["channel_type", "stream"], + ["about", SESSION_CHANNEL_DESCRIPTION], + ], + }); + test.setItems([{ event: creation, delivery: "unknown" }]); + test.reader.read.mockResolvedValueOnce([creation]); + await test.service.delivered(creation.id); + expect(test.outbox.retry).not.toHaveBeenCalled(); + expect(test.reader.read).toHaveBeenCalledWith( + [ + { + kinds: [39000, 39002], + "#d": ["11111111-1111-4111-8111-111111111111"], + limit: 2, + }, + { ids: [creation.id], limit: 1 }, + ], + expect.anything(), + ); +}); + +it.each([true, false])( + "checks fresh roster access before recovering creation (member: %s)", + async (member) => { + const viewer = keypair(), + relay = keypair(); + const channelId = "11111111-1111-4111-8111-111111111111"; + const creation = signed(viewer, { + kind: 9007, + content: "", + tags: [["h", channelId]], + }); + const owner = createRelaySession({ + viewer: viewer.pubkey, + relayAuthor: relay.pubkey, + media: () => undefined, + query: async (filters) => + filters.some((filter) => filter.ids) + ? [creation, roster(relay, channelId, member ? [viewer.pubkey] : [])] + : [], + }); + try { + owner.session.channels.ensureList?.(); + await vi.waitFor(() => + expect(owner.session.channels.list().status).toBe("ready"), + ); + let items: readonly OutgoingEvent[] = [ + { event: creation, delivery: "unknown" }, + ]; + const outbox: Outbox = { + supports: () => true, + send: vi.fn(), + snapshot: () => items, + subscribe: () => () => {}, + retry: vi.fn(() => { + items = [{ event: creation, delivery: "failed" }]; + }), + dismiss: async () => {}, + }; + const service = createWorkSessions( + outbox, + owner.session.channels, + { + read: async (filters, options) => { + const visible = await owner.session.read(filters, options); + return visible.some((event) => event.id === creation.id) + ? [creation] + : []; + }, + }, + new AbortController().signal, + ); + if (member) { + await service.delivered(creation.id); + expect(outbox.retry).not.toHaveBeenCalled(); + } else { + await expect(service.delivered(creation.id)).rejects.toThrow( + /could not be confirmed/, + ); + expect(outbox.retry).toHaveBeenCalledWith(creation.id); + } + } finally { + owner.dispose(); + } + }, +); diff --git a/src/features/relay/work-sessions.ts b/src/features/relay/work-sessions.ts new file mode 100644 index 00000000..29d65237 --- /dev/null +++ b/src/features/relay/work-sessions.ts @@ -0,0 +1,331 @@ +import { sessionDescription } from "../sessions/metadata"; +import type { Outbox } from "./outbox"; +import type { ChannelQueries } from "./contracts"; +import type { RelayReader } from "./reader"; + +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** Work-session commands use the same durable outbox and connection lifetime. */ +export function createWorkSessions( + outbox: Outbox | undefined, + channels: ChannelQueries, + reader: RelayReader, + signal: AbortSignal, + receipts?: Pick, + confirmCreation?: (id: string) => Promise, + agentKeys?: () => readonly string[], + relayAuthor?: string, +) { + const available = !!outbox?.supports(9007); + function writer() { + if (signal.aborted || !available || !outbox) + throw new Error( + "This community does not support saved sessions yet. Your draft is kept here.", + ); + return outbox; + } + function identifier(id: string) { + if (!uuid.test(id)) throw new Error("Invalid session identifier"); + } + async function delivered(id: string) { + const source = writer(); + const journal = receipts ?? source; + const existing = journal.snapshot().find((item) => item.event.id === id); + if (!existing) { + if (await confirmCreation?.(id)) return; + const events = await reader.read([{ ids: [id], limit: 1 }], { signal }); + if (events.some((event) => event.id === id)) return; + throw new Error( + "The saved operation could not be confirmed. Reconnect and retry.", + ); + } + if (existing.delivery === "failed" || existing.delivery === "unknown") { + // Ordinary channel creation rejects a repeated channel UUID. An exact, + // verified creation event can confirm an earlier lost acknowledgment. + if (existing.event.kind === 9007) { + if (await confirmCreation?.(id)) return; + const channelId = existing.event.tags.find( + ([name]) => name === "h", + )?.[1]; + if (!channelId) + throw new Error("The saved channel creation is invalid."); + // A missed membership notification must not leave the readback hidden + // by a stale roster. The verified reader applies signed discovery first. + const events = await reader.read( + [ + { kinds: [39000, 39002], "#d": [channelId], limit: 2 }, + { ids: [id], limit: 1 }, + ], + { signal }, + ); + if ( + events.some( + (event) => + event.id === id && event.pubkey === existing.event.pubkey, + ) + ) + return; + } + source.retry(id); + } + await new Promise((resolve, reject) => { + let unsubscribe = () => {}; + const finish = (error?: Error) => { + unsubscribe(); + clearTimeout(timer); + signal.removeEventListener("abort", aborted); + error ? reject(error) : resolve(); + }; + const aborted = () => + finish( + new Error("The community connection changed. Your draft is kept."), + ); + const timer = setTimeout( + () => + finish( + new Error( + "Still waiting for confirmation. Retry without starting another session.", + ), + ), + 15000, + ); + const inspect = () => { + const item = journal.snapshot().find((item) => item.event.id === id); + if (item?.delivery === "accepted" || item?.delivery === "seen") + finish(); + else if (item?.delivery === "failed" || item?.delivery === "unknown") + finish( + new Error(item.error ?? "The operation could not be confirmed."), + ); + }; + unsubscribe = journal.subscribe(inspect); + signal.addEventListener("abort", aborted, { once: true }); + if (signal.aborted) aborted(); + else inspect(); + }); + } + async function refresh( + id: string, + expected: { + member?: string; + members?: readonly string[]; + parent?: string; + } = {}, + sessionOnly = true, + ) { + writer(); + const wait = new Promise((resolve, reject) => { + let unsubscribe = () => {}; + const done = (error?: Error) => { + unsubscribe(); + clearTimeout(timer); + signal.removeEventListener("abort", abort); + error ? reject(error) : resolve(); + }; + const abort = () => done(new Error("The community connection changed.")); + const timer = setTimeout( + () => + done( + new Error( + "Session saved, but its membership is still loading. Retry to continue.", + ), + ), + 15000, + ); + const inspect = () => { + const list = channels.list(); + if (list.status === "error") + done(new Error(list.error ?? "Session membership could not load.")); + else if ( + list.status === "ready" && + list.channels.some( + (channel) => + channel.id === id && + (!sessionOnly || channel.channelType === "session") && + (!expected.member || + channel.members?.includes(expected.member)) && + (!expected.members || + expected.members.every((key) => + channel.members?.includes(key), + )) && + (!expected.parent || channel.parentChannelId === expected.parent), + ) + ) + done(); + }; + unsubscribe = channels.subscribeList(inspect); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + }); + channels.refreshList?.(); + await wait; + } + async function refreshMembership(id: string) { + writer(); + if (!relayAuthor) throw new Error("Channel membership is unavailable."); + const events = await reader.read( + [{ kinds: [39002], authors: [relayAuthor], "#d": [id], limit: 1 }], + { signal, fresh: true, priority: "foreground" }, + ); + const channel = channels.list().channels.find((item) => item.id === id); + if ( + !events.some( + (event) => + event.kind === 39002 && + event.pubkey === relayAuthor && + event.tags.some(([name, value]) => name === "d" && value === id), + ) || + !channel?.members || + channel.archived + ) + throw new Error( + "Could not refresh channel membership. Retry to continue.", + ); + return channel; + } + async function addAgents( + id: string, + keys: readonly string[], + active = () => true, + ) { + const find = () => channels.list().channels.find((item) => item.id === id); + const original = await refreshMembership(id); + if (!active()) + throw new DOMException("Session submission cancelled", "AbortError"); + const unique = [...new Set(keys)].filter( + (key) => + original.channelType !== "session" || !original.members?.includes(key), + ); + if (!unique.length) return; + const parentId = original.parentChannelId ?? id; + const parent = + parentId === id ? original : await refreshMembership(parentId); + if ( + !parentId || + !parent?.members || + parent.archived || + !["stream", "forum", "session"].includes(parent.channelType ?? "") + ) + throw new Error("Refresh the parent channel before adding agents."); + const known = new Set(agentKeys?.() ?? []); + if (unique.some((key) => !parent.members?.includes(key) && !known.has(key))) + throw new Error("Choose an agent from your agent library."); + // Parent metadata organizes the UI; both channels keep their own rosters. + const targets = parentId !== id ? [parentId, id] : [parentId]; + for (const targetId of targets) { + for (const key of unique) { + if (!active()) + throw new DOMException("Session submission cancelled", "AbortError"); + writer(); + if (find()?.parentChannelId !== original?.parentChannelId) + throw new Error("This session moved. Refresh and retry."); + const current = channels + .list() + .channels.find((item) => item.id === targetId); + if (!current?.members || current.archived) + throw new Error("Channel membership is unavailable."); + if (current.members.includes(key)) continue; + const previous = (receipts ?? outbox) + ?.snapshot() + .find( + (item) => + item.event.kind === 9000 && + !["accepted", "seen"].includes(item.delivery) && + item.event.tags.some( + ([name, value]) => name === "h" && value === targetId, + ) && + item.event.tags.some( + ([name, value]) => name === "p" && value === key, + ) && + !item.event.tags.some(([name]) => name === "role"), + ); + const operation = + previous?.event.id ?? + writer().send({ + kind: 9000, + content: "", + tags: [ + ["h", targetId], + ["p", key], + ], + }); + await delivered(operation); + await refresh(targetId, { member: key }, false); + } + } + if (original?.channelType === "session") + await refresh(id, { + members: unique, + ...(original.parentChannelId ? { parent: parentId } : {}), + }); + } + return Object.freeze({ + available, + addAgents, + refreshMembership, + create(id: string, title: string, parentId?: string) { + writer(); + identifier(id); + if (parentId) identifier(parentId); + if (!title.trim() || [...title].length > 120) + throw new Error("Use a session title between 1 and 120 characters."); + if (parentId === id) + throw new Error("A session cannot be its own parent."); + return writer().send({ + kind: 9007, + content: "", + tags: [ + ["h", id], + ["name", title], + ["visibility", "private"], + ["channel_type", "stream"], + ["about", sessionDescription(parentId)], + ], + }); + }, + invite(id: string, pubkey: string) { + identifier(id); + const target = channels + .list() + .channels.find((channel) => channel.id === id); + if (target?.channelType !== "session" || target.parentChannelId) + throw new Error( + "Manage this session’s participants in its parent channel.", + ); + if (!/^[0-9a-f]{64}$/.test(pubkey)) + throw new Error("Choose a valid participant."); + if (!new Set(agentKeys?.() ?? []).has(pubkey)) + throw new Error("Choose an agent from your agent library."); + return writer().send({ + kind: 9000, + content: "", + tags: [ + ["h", id], + ["p", pubkey], + ], + }); + }, + failed(id: string) { + return ( + (receipts ?? outbox) + ?.snapshot() + .some((item) => item.event.id === id && item.delivery === "failed") ?? + false + ); + }, + async discardFailed(id: string) { + const source = writer(); + if ( + !(receipts ?? source) + .snapshot() + .some((item) => item.event.id === id && item.delivery === "failed") + ) + throw new Error( + "This operation is still unconfirmed. Retry to confirm it first.", + ); + await source.dismiss(id); + }, + delivered, + refresh, + }); +} diff --git a/src/features/sessions/AgentChoice.test.tsx b/src/features/sessions/AgentChoice.test.tsx new file mode 100644 index 00000000..0b8bba66 --- /dev/null +++ b/src/features/sessions/AgentChoice.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StrictMode } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { createAgentLibrary } from "../agents/library"; +import type { RelaySession } from "../relay/session"; +import { AgentChoice } from "./AgentChoice"; + +afterEach(cleanup); + +it("reloads the selected agent after the connection clears the library", async () => { + const pubkey = "a".repeat(64); + const read = vi.fn(async () => ({ + definitions: [], + identities: [{ pubkey, name: "Selected agent" }], + })); + const library = createAgentLibrary(read); + const session = { agentLibrary: library.queries } as RelaySession; + const view = render( + + + , + ); + await screen.findByRole("button", { name: "Change agent: Selected agent" }); + await act(async () => library.clear()); + await waitFor(() => expect(read).toHaveBeenCalledTimes(2)); + await screen.findByRole("button", { name: "Change agent: Selected agent" }); + view.unmount(); + library.dispose(); +}); + +it("opens the avatar menu and changes the chosen agent without submitting", async () => { + const user = userEvent.setup(); + const pubkey = "a".repeat(64); + const library = createAgentLibrary(async () => ({ + definitions: [], + identities: [{ pubkey, name: "Fizz" }], + })); + const onChange = vi.fn(), + onSubmit = vi.fn(); + render( +
+ + , + ); + await user.click( + await screen.findByRole("button", { name: "Choose an agent" }), + ); + await user.click(await screen.findByRole("menuitemradio", { name: "Fizz" })); + expect(onChange).toHaveBeenCalledWith(pubkey, expect.anything()); + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + library.dispose(); +}); diff --git a/src/features/sessions/AgentChoice.tsx b/src/features/sessions/AgentChoice.tsx new file mode 100644 index 00000000..cc63ec1c --- /dev/null +++ b/src/features/sessions/AgentChoice.tsx @@ -0,0 +1,174 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { Menu } from "@base-ui/react/menu"; +import { Bot, Check, ChevronUp } from "lucide-react"; +import type { RelaySession } from "../relay/session"; +import { Avatar } from "../../shared/Avatar"; +import { avatarSource } from "../../shared/avatar-source"; +import completion from "../conversation/Completions.module.css"; +import styles from "./Sessions.module.css"; + +export function AgentChoice({ + session, + value, + onChange, + disabled = false, + allowed, + parentName, + emptyLabel = "No agent selected", + side = "top", +}: { + session: RelaySession; + value: string; + onChange: (key: string) => void; + disabled?: boolean; + allowed?: readonly string[] | undefined; + parentName?: string | undefined; + emptyLabel?: string; + side?: "top" | "bottom"; +}) { + const library = session.agentLibrary; + const agents = useSyncExternalStore( + library.subscribe, + library.snapshot, + library.snapshot, + ); + useEffect(() => { + if (agents.status === "idle") void library.refresh(); + }, [library, agents.status]); + const identities = agents.identities; + const selected = identities.find((agent) => agent.pubkey === value); + function picture(avatar?: string) { + const source = avatarSource(avatar); + return source?.startsWith("data:") + ? source + : source + ? session.media(source, "small") + : undefined; + } + const label = selected + ? `Change agent: ${selected.name}` + : value + ? "Change selected agent" + : "Choose an agent"; + return ( + + + {selected ? ( + + ) : ( + + + + + + + + {identities.map((agent) => { + const outside = + allowed !== undefined && !allowed.includes(agent.pubkey); + const duplicateName = agents.identities.some( + (other) => + other.pubkey !== agent.pubkey && other.name === agent.name, + ); + return ( + + + + {agent.name} + {duplicateName ? ` · ${agent.pubkey.slice(0, 8)}` : ""} + {outside ? " — adds to channel" : ""} + + + + + + ); + })} + + {allowed !== undefined && + agents.identities.some( + (agent) => !allowed.includes(agent.pubkey), + ) && ( +

+ Adding an agent also adds it to{" "} + {parentName ?? "the parent channel"}, with access to its + history. +

+ )} + {agents.status === "loading" && ( +

Loading agents…

+ )} + {agents.status === "ready" && !agents.identities.length && ( +

No agents found in your Buzz library.

+ )} + {agents.status === "unavailable" && ( +

+ Your agent library isn’t available on this connection. +

+ )} + {agents.status === "error" && ( + void library.refresh()} + > + Retry agent list + + )} +
+
+
+
+ ); +} diff --git a/src/features/sessions/NewSessionComposer.test.tsx b/src/features/sessions/NewSessionComposer.test.tsx new file mode 100644 index 00000000..0bf5b411 --- /dev/null +++ b/src/features/sessions/NewSessionComposer.test.tsx @@ -0,0 +1,429 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { RelaySession } from "../relay/session"; +import { writeView } from "../../shared/view-state"; +import { NewSessionComposer } from "./NewSessionComposer"; + +afterEach(cleanup); +beforeEach(() => localStorage.clear()); +const parent = { + id: "11111111-1111-4111-8111-111111111111", + name: "Engineering", + channelType: "stream" as const, + members: ["a".repeat(64)], +}; +function setup(available = true) { + const agents = { + status: "ready", + identities: [ + { pubkey: "a".repeat(64), name: "Member agent" }, + { pubkey: "b".repeat(64), name: "Outside agent" }, + ], + }; + const emoji = { status: "ready", entries: [] }; + const workSessions = { + available, + addAgents: vi.fn(async () => {}), + create: vi.fn(() => "c".repeat(64)), + invite: vi.fn(() => "e".repeat(64)), + delivered: vi.fn(async () => {}), + refresh: vi.fn(async () => {}), + failed: () => false, + }; + const messages = { send: vi.fn(() => "d".repeat(64)) }; + let channelSnapshot = { channels: [{ id: "", members: parent.members }] }; + const profileSnapshot = new Map(); + const session = { + workSessions, + messages, + channels: { + list: () => { + const id = workSessions.create.mock.calls[0]?.[0] ?? ""; + if (channelSnapshot.channels[0]?.id !== id) + channelSnapshot = { channels: [{ id, members: parent.members }] }; + return channelSnapshot; + }, + subscribeList: () => () => {}, + }, + profiles: { + ensure: vi.fn(async () => {}), + snapshot: () => profileSnapshot, + subscribe: () => () => {}, + }, + agentLibrary: { + snapshot: () => agents, + subscribe: () => () => {}, + refresh: async () => {}, + }, + emoji: { + snapshot: () => emoji, + subscribe: () => () => {}, + ensure: async () => {}, + }, + outbox: { supports: () => true }, + media: (url: string) => url, + } as unknown as RelaySession; + return { session, workSessions, messages }; +} +it.each([true, false])( + "admits only explicit mentions when they override the picker (child: %s)", + async (child) => { + const test = setup(), + onStarted = vi.fn(), + user = userEvent.setup(); + writeView( + "test", + `${child ? `sessions:channel:${parent.id}` : "sessions"}:new-draft`, + { + text: "@Member agent Plan the release", + recipients: [ + { pubkey: "a".repeat(64), name: "Member agent", start: 0, end: 13 }, + ], + }, + ); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Choose an agent" })); + expect( + await screen.findByRole("menuitemradio", { + name: /^Outside agent/, + }), + ).toBeVisible(); + await user.click( + await screen.findByRole("menuitemradio", { name: /^Outside agent/ }), + ); + expect( + screen.getByRole("textbox", { name: "Message this session" }), + ).toHaveTextContent("Plan the release"); + await user.click(screen.getByRole("textbox")); + await user.keyboard("{Enter}"); + await waitFor(() => expect(onStarted).toHaveBeenCalled()); + const id = test.workSessions.create.mock.calls[0]?.[0]; + expect(test.workSessions.create).toHaveBeenCalledWith( + expect.any(String), + "@Member agent Plan the release", + child ? parent.id : undefined, + ); + expect(test.workSessions.invite).not.toHaveBeenCalled(); + expect(test.workSessions.addAgents.mock.calls).toEqual( + (child ? [parent.id, id] : [id]).map((target) => [ + target, + ["a".repeat(64)], + expect.any(Function), + ]), + ); + expect(test.messages.send).toHaveBeenCalledWith( + id, + "@Member agent Plan the release", + ["a".repeat(64)], + ); + }, +); +it("keeps parent drafts separate and cannot send on an unsupported community", async () => { + const test = setup(false), + user = userEvent.setup(); + const view = render( + {}} + />, + ); + await user.type(screen.getByRole("textbox"), "Parent draft"); + expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(); + view.unmount(); + const solo = render( + {}} + />, + ); + expect(screen.getByRole("textbox").textContent).toBe(""); + solo.unmount(); + render( + {}} + />, + ); + expect(screen.getByRole("textbox")).toHaveTextContent("Parent draft"); + expect(test.workSessions.create).not.toHaveBeenCalled(); +}); + +it("keeps the prompt through an uncertain delivery and retries without duplicate writes", async () => { + const test = setup(), + onStarted = vi.fn(), + user = userEvent.setup(); + test.workSessions.delivered.mockRejectedValueOnce( + new Error("Still waiting for delivery"), + ); + const view = render( + , + ); + await user.type(screen.getByRole("textbox"), "Recover this prompt"); + await user.click(screen.getByRole("button", { name: "Send message" })); + await screen.findByRole("alert"); + expect(screen.getByRole("textbox")).toHaveTextContent("Recover this prompt"); + expect(test.messages.send).not.toHaveBeenCalled(); + view.unmount(); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(onStarted).toHaveBeenCalledOnce()); + expect(test.workSessions.create).toHaveBeenCalledOnce(); + expect(test.messages.send).toHaveBeenCalledOnce(); +}); + +it("restores the chosen agent and invites it before the first standalone message", async () => { + const test = setup(), + onStarted = vi.fn(), + user = userEvent.setup(); + const view = render( + , + ); + await user.click( + screen.getByRole("button", { name: /Choose an agent|Change agent:/ }), + ); + await user.click( + await screen.findByRole("menuitemradio", { name: /^Outside agent/ }), + ); + await user.type(screen.getByRole("textbox"), "Help with the release"); + view.unmount(); + render( + , + ); + expect( + screen.getByRole("button", { name: "Change agent: Outside agent" }), + ).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(onStarted).toHaveBeenCalledOnce()); + const id = test.workSessions.create.mock.calls[0]?.[0]; + expect(test.workSessions.invite).toHaveBeenCalledExactlyOnceWith( + id, + "b".repeat(64), + ); + expect(test.workSessions.refresh).toHaveBeenCalledWith(id, { + member: "b".repeat(64), + }); + expect(test.messages.send).toHaveBeenCalledExactlyOnceWith( + id, + "Help with the release", + ["b".repeat(64)], + ); + expect(test.workSessions.invite.mock.invocationCallOrder[0]).toBeLessThan( + test.messages.send.mock.invocationCallOrder[0] ?? 0, + ); +}); + +it("loads the new session roster profiles before sending without an explicit agent", async () => { + const test = setup(), + user = userEvent.setup(), + onStarted = vi.fn(); + let finish: () => void = () => {}; + vi.mocked(test.session.profiles.ensure).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + render( + , + ); + await user.type(screen.getByRole("textbox"), "Keep going"); + await user.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => + expect(test.session.profiles.ensure).toHaveBeenCalledWith( + parent.members, + "background", + ), + ); + expect(test.messages.send).not.toHaveBeenCalled(); + finish(); + await waitFor(() => expect(onStarted).toHaveBeenCalledOnce()); + expect(test.messages.send).toHaveBeenCalledOnce(); +}); + +it("deduplicates the effective recipient before parent admission", async () => { + const test = setup(), + user = userEvent.setup(), + onStarted = vi.fn(); + let release = () => {}; + test.workSessions.addAgents.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + writeView("test", `sessions:channel:${parent.id}:new-draft`, { + text: "@Outside agent Help", + recipients: [ + { pubkey: "b".repeat(64), name: "Outside agent", start: 0, end: 14 }, + ], + }); + render( + , + ); + await user.click( + screen.getByRole("button", { name: /Choose an agent|Change agent:/ }), + ); + await user.click( + await screen.findByRole("menuitemradio", { name: /^Outside agent/ }), + ); + await user.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => + expect(test.workSessions.addAgents).toHaveBeenCalledWith( + parent.id, + ["b".repeat(64)], + expect.any(Function), + ), + ); + try { + expect(test.workSessions.create).not.toHaveBeenCalled(); + expect(test.messages.send).not.toHaveBeenCalled(); + } finally { + release(); + } + await waitFor(() => expect(onStarted).toHaveBeenCalledOnce()); + expect(test.messages.send).toHaveBeenCalledWith( + expect.any(String), + "@Outside agent Help", + ["b".repeat(64)], + ); +}); +it("keeps an editable draft when parent admission fails and retries admission first", async () => { + const test = setup(), + user = userEvent.setup(), + onStarted = vi.fn(); + test.workSessions.addAgents.mockRejectedValueOnce( + new Error("Only channel admins can add agents"), + ); + render( + , + ); + await user.click( + screen.getByRole("button", { name: /Choose an agent|Change agent:/ }), + ); + await user.click( + await screen.findByRole("menuitemradio", { name: /^Outside agent/ }), + ); + await user.type(screen.getByRole("textbox"), "Help"); + await user.click(screen.getByRole("button", { name: "Send message" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Only channel admins", + ); + expect(screen.getByRole("textbox")).toHaveTextContent("Help"); + expect(screen.getByRole("textbox")).not.toBeDisabled(); + expect(test.workSessions.create).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(onStarted).toHaveBeenCalledOnce()); + expect(test.workSessions.addAgents).toHaveBeenCalledTimes(3); +}); + +it.each( + [true, false].flatMap((child) => + [true, false].map((sent) => ({ child, sent })), + ), +)( + "restores effective recipients without replaying an overridden invitation: child=$child, sent=$sent", + async ({ child, sent }) => { + const test = setup(), + onStarted = vi.fn(), + user = userEvent.setup(); + const key = child ? `sessions:channel:${parent.id}` : "sessions"; + const id = "22222222-2222-4222-8222-222222222222"; + const draft = { + text: "@Member agent Continue", + recipients: [ + { pubkey: "a".repeat(64), name: "Member agent", start: 0, end: 13 }, + ], + }; + writeView("test", `${key}:pending`, { + id, + text: draft.text, + draft, + agent: "b".repeat(64), + creationId: "c".repeat(64), + invitationId: "e".repeat(64), + ...(sent ? { messageId: "d".repeat(64) } : {}), + }); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(onStarted).toHaveBeenCalledOnce()); + expect(test.workSessions.create).not.toHaveBeenCalled(); + expect(test.workSessions.invite).not.toHaveBeenCalled(); + expect(test.workSessions.delivered).not.toHaveBeenCalledWith( + "e".repeat(64), + ); + if (sent) { + expect(test.workSessions.addAgents).not.toHaveBeenCalled(); + expect(test.messages.send).not.toHaveBeenCalled(); + } else { + expect(test.workSessions.addAgents.mock.calls).toEqual( + (child ? [parent.id, id] : [id]).map((target) => [ + target, + ["a".repeat(64)], + expect.any(Function), + ]), + ); + expect(test.messages.send).toHaveBeenCalledExactlyOnceWith( + id, + draft.text, + ["a".repeat(64)], + ); + } + }, +); diff --git a/src/features/sessions/NewSessionComposer.tsx b/src/features/sessions/NewSessionComposer.tsx new file mode 100644 index 00000000..fa197075 --- /dev/null +++ b/src/features/sessions/NewSessionComposer.tsx @@ -0,0 +1,300 @@ +import { useEffect, useRef, useState } from "react"; +import type { RelaySession } from "../relay/session"; +import type { ChannelSummary } from "../relay/contracts"; +import { readView, writeView } from "../../shared/view-state"; +import { MessageComposer } from "../messages/MessageComposer"; +import { mentionDraft, type MentionDraft } from "../messages/mention-draft"; +import type { ConversationExtensions } from "../conversation/contracts"; +import { AgentChoice } from "./AgentChoice"; +import styles from "./Sessions.module.css"; + +type PendingStart = { + id: string; + text: string; + draft?: MentionDraft; + agent?: string; + invitationId?: string; + creationId?: string; + messageId?: string; +}; +function readPending( + scope: string, + draftKey: string, +): PendingStart | undefined { + const value = readView | null>( + scope, + `${draftKey}:pending`, + null, + ); + if ( + !value || + typeof value.id !== "string" || + !/^[0-9a-f-]{36}$/.test(value.id) || + typeof value.text !== "string" || + value.text.length > 16000 + ) + return; + if ( + [value.creationId, value.messageId, value.invitationId, value.agent].some( + (id) => + id !== undefined && + (typeof id !== "string" || !/^[0-9a-f]{64}$/.test(id)), + ) + ) + return; + return value as PendingStart; +} +export function NewSessionComposer({ + session, + scope, + onStarted, + parent, + extensions, +}: { + extensions?: ConversationExtensions | undefined; + session: RelaySession; + scope: string; + onStarted: (id: string) => void; + parent?: ChannelSummary | undefined; +}) { + const available = session.workSessions.available; + const draftKey = parent ? `sessions:channel:${parent.id}` : "sessions"; + const [pending, setPending] = useState(() => readPending(scope, draftKey)); + const [agent, setAgent] = useState(() => { + const saved = readView(scope, `${draftKey}:new-agent`, ""); + return ( + pending?.agent ?? + (typeof saved === "string" && /^[0-9a-f]{64}$/.test(saved) ? saved : "") + ); + }); + const [channelId] = useState(() => pending?.id ?? crypto.randomUUID()); + const [editing, setEditing] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const submitting = useRef(false); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const container = useRef(null); + useEffect(() => { + container.current?.querySelector('[role="textbox"]')?.focus(); + }, []); + const save = (next: PendingStart) => { + setPending(next); + writeView(scope, `${draftKey}:pending`, next); + }; + async function start(draft: MentionDraft) { + if ( + submitting.current || + !(pending?.text ?? draft.text).trim() || + !available + ) + return; + submitting.current = true; + setBusy(true); + setEditing(false); + setError(undefined); + const current = pending + ? { + ...pending, + ...(!pending.messageId ? { text: draft.text, draft } : {}), + } + : { id: channelId, text: draft.text, draft, ...(agent ? { agent } : {}) }; + + const mentions = mentionDraft(current.draft).recipients.map( + (item) => item.pubkey, + ); + const recipients = [ + ...new Set( + mentions.length ? mentions : current.agent ? [current.agent] : [], + ), + ]; + save(current); + try { + if (parent && !current.messageId && recipients.length) { + await session.agentLibrary.refresh(); + if (!mounted.current) return; + await session.workSessions.addAgents( + parent.id, + recipients, + () => mounted.current, + ); + if (!mounted.current) return; + } + if (!current.creationId) { + current.creationId = session.workSessions.create( + current.id, + [...current.text.trim().replace(/\s+/g, " ")].slice(0, 80).join(""), + parent?.id, + ); + save({ ...current }); + } + await session.workSessions.delivered(current.creationId); + if (!mounted.current) return; + await session.workSessions.refresh( + current.id, + parent ? { parent: parent.id } : undefined, + ); + if (!mounted.current) return; + if (parent && !current.messageId) { + await session.workSessions.addAgents( + current.id, + recipients, + () => mounted.current, + ); + if (!mounted.current) return; + } + if (!current.messageId && current.agent && !parent && !mentions.length) { + if (!current.invitationId) { + current.invitationId = session.workSessions.invite( + current.id, + current.agent, + ); + save({ ...current }); + } + await session.workSessions.delivered(current.invitationId); + if (!mounted.current) return; + await session.workSessions.refresh(current.id, { + member: current.agent, + }); + if (!mounted.current) return; + } + if (!current.messageId) { + if (!parent && mentions.length) { + await session.workSessions.addAgents( + current.id, + recipients, + () => mounted.current, + ); + if (!mounted.current) return; + } + if (!recipients.length) { + const channel = session.channels + .list() + .channels.find((item) => item.id === current.id); + if (!channel?.members) + throw new Error("Refresh session participants before sending."); + await Promise.all([ + session.profiles.ensure(channel.members, "background"), + session.agentLibrary.snapshot().status === "ready" + ? Promise.resolve() + : session.agentLibrary.refresh(), + ]); + if (!mounted.current) return; + } + current.messageId = session.messages.send( + current.id, + current.text, + recipients, + ); + save({ ...current }); + } + await session.workSessions.delivered(current.messageId); + if (!mounted.current) return; + writeView(scope, `${draftKey}:pending`, null); + writeView(scope, `${draftKey}:new-draft`, ""); + writeView(scope, `${draftKey}:new-agent`, ""); + setAgent(""); + setPending(undefined); + onStarted(current.id); + } catch (reason) { + if (!current.messageId && mounted.current) setEditing(true); + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + submitting.current = false; + setBusy(false); + } + } + const failedId = + pending && + [pending.messageId, pending.invitationId, pending.creationId].find( + (id) => id && session.workSessions.failed(id), + ); + async function editFailed() { + if (!pending || !failedId || busy) return; + setBusy(true); + try { + await session.workSessions.discardFailed(failedId); + if (failedId === pending.creationId) { + writeView(scope, `${draftKey}:pending`, null); + setPending(undefined); + } else { + const next = { ...pending }; + if (failedId === next.invitationId) { + delete next.invitationId; + delete next.agent; + setAgent(""); + writeView(scope, `${draftKey}:new-agent`, ""); + } else delete next.messageId; + save(next); + } + setEditing(true); + setError(undefined); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setBusy(false); + } + } + return ( +
+ { + setAgent(value); + writeView(scope, `${draftKey}:new-agent`, value); + if (pending) { + const next = { ...pending }; + if (value) next.agent = value; + else delete next.agent; + save(next); + } + setError(undefined); + }} + /> + } + session={session} + extensions={extensions} + scope={scope} + channelId={parent?.id ?? channelId} + channelName={parent?.name ?? "this session"} + label="Message this session" + inviteAgents + submission={{ + draftKey: `${draftKey}:new-draft`, + initialDraft: pending?.draft ?? pending?.text, + locked: busy || (!!pending && !editing), + disabled: busy || !available, + submit: (draft) => { + void start(draft); + }, + }} + /> + {!available && ( +

+ This connection can’t send messages. Your draft is saved. +

+ )} + {error &&

{error}

} + {error && failedId && ( + + )} +
+ ); +} diff --git a/src/features/sessions/SessionAgentControl.test.tsx b/src/features/sessions/SessionAgentControl.test.tsx new file mode 100644 index 00000000..5a778485 --- /dev/null +++ b/src/features/sessions/SessionAgentControl.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { afterEach, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { RelaySession } from "../relay/session"; +import type { AgentLibrarySnapshot } from "../agents/library"; +import { SessionAgentControl } from "./SessionAgentControl"; + +afterEach(cleanup); +it("retries the failed library and updates the picker when a second agent joins", async () => { + const viewer = "a".repeat(64), + agent = "b".repeat(64), + other = "c".repeat(64); + let roster = { + channels: [{ id: "session", name: "Work", members: [viewer, agent] }], + }; + const profiles = new Map([ + [viewer, { name: "Kenny" }], + [agent, { name: "Helper" }], + [other, { name: "Second", isAgent: true as const }], + ]); + let library: AgentLibrarySnapshot = { + status: "error", + definitions: [], + identities: [], + }; + const listeners = new Set<() => void>(); + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + const refresh = vi.fn(async () => { + library = { + status: "ready", + definitions: [], + identities: [{ pubkey: agent, name: "Helper" }], + }; + for (const listener of listeners) listener(); + }); + const ensure = vi.fn(async () => {}); + const session = { + viewer, + channels: { list: () => roster, subscribeList: subscribe }, + profiles: { snapshot: () => profiles, subscribe, ensure }, + agentLibrary: { snapshot: () => library, subscribe, refresh }, + } as unknown as RelaySession; + render( + {}} + disabled={false} + />, + ); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Choose an agent" })); + await user.click( + await screen.findByRole("menuitem", { name: "Retry agent list" }), + ); + await user.keyboard("{Escape}"); + expect(refresh).toHaveBeenCalledOnce(); + expect( + await screen.findByRole("button", { name: "Change agent: Helper" }), + ).toBeInTheDocument(); + act(() => { + roster = { + channels: [ + { id: "session", name: "Work", members: [viewer, agent, other] }, + ], + }; + for (const listener of listeners) listener(); + }); + expect( + await screen.findByRole("button", { name: "Choose an agent" }), + ).toBeInTheDocument(); + expect(ensure).toHaveBeenCalledWith([viewer, agent, other], "background"); +}); diff --git a/src/features/sessions/SessionAgentControl.tsx b/src/features/sessions/SessionAgentControl.tsx new file mode 100644 index 00000000..5a32c3de --- /dev/null +++ b/src/features/sessions/SessionAgentControl.tsx @@ -0,0 +1,56 @@ +import { useEffect, useSyncExternalStore } from "react"; +import type { RelaySession } from "../relay/session"; +import { AgentChoice } from "./AgentChoice"; +import { sessionAgents } from "./recipients"; + +/** The selected target is a composer choice; only relay membership grants access. */ +export function SessionAgentControl({ + session, + channelId, + value, + onChange, + disabled, +}: { + session: RelaySession; + channelId: string; + value: string; + onChange: (key: string) => void; + disabled: boolean; +}) { + const list = useSyncExternalStore( + session.channels.subscribeList, + session.channels.list, + ); + const profiles = useSyncExternalStore( + session.profiles.subscribe, + session.profiles.snapshot, + ); + const library = useSyncExternalStore( + session.agentLibrary.subscribe, + session.agentLibrary.snapshot, + ); + const channel = list.channels.find((item) => item.id === channelId); + const parent = list.channels.find( + (item) => item.id === channel?.parentChannelId, + ); + const memberKey = channel?.members?.join(":") ?? ""; + useEffect(() => { + if (memberKey) + void session.profiles + .ensure(memberKey.split(":"), "background") + .catch(() => {}); + }, [session, memberKey]); + const agents = sessionAgents(channel, profiles, library, session.viewer); + const implicit = agents?.length === 1 ? agents[0] : ""; + return ( + + ); +} diff --git a/src/features/sessions/SessionMessageTarget.test.tsx b/src/features/sessions/SessionMessageTarget.test.tsx new file mode 100644 index 00000000..7b96d7f2 --- /dev/null +++ b/src/features/sessions/SessionMessageTarget.test.tsx @@ -0,0 +1,214 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { StrictMode } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { RelaySession } from "../relay/session"; +import type { ThreadSnapshot } from "../relay/threads"; +import type { PageNavigation } from "../navigation/service"; +import { SessionMessageTarget } from "./SessionMessageTarget"; + +beforeEach(() => { + // Resize observation is a browser boundary; layout/focus is tested in Playwright. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +function setup() { + const views: ReturnType[] = []; + function makeView() { + let snapshot: ThreadSnapshot = { + status: "loading", + root: undefined, + replies: [], + error: undefined, + canLoadMore: false, + limited: false, + targetStatus: "loading", + }; + const listeners = new Set<() => void>(); + return { + snapshot: () => snapshot, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + refresh: vi.fn(async () => {}), + loadMore: vi.fn(async () => {}), + dispose: vi.fn(), + update(next: Partial) { + snapshot = { ...snapshot, ...next }; + for (const listener of listeners) listener(); + }, + }; + } + const profiles = new Map(); + const channels = { status: "ready", channels: [] }; + const session = { + thread: vi.fn(() => { + const view = makeView(); + views.push(view); + return view; + }), + profiles: { + snapshot: () => profiles, + subscribe: () => () => {}, + ensure: async () => {}, + }, + channels: { list: () => channels, subscribeList: () => () => {} }, + media: () => undefined, + } as unknown as RelaySession; + function request(id: string) { + const controller = new AbortController(); + const navigation = { + entryId: id, + signal: controller.signal, + complete: vi.fn((result) => { + if (result.status === "failed") controller.abort(); + return true; + }), + } as unknown as PageNavigation; + return { navigation, controller }; + } + const props = { + session, + scope: "test", + channelId: "channel", + messageId: "target", + onOpenLink: () => false, + onLatest: vi.fn(), + onRetry: vi.fn(), + }; + return { views, session, request, props }; +} + +it.each(["unavailable", "error"] as const)( + "reports %s and offers a fresh navigation retry", + async (targetStatus) => { + const test = setup(), + { navigation } = test.request("first"); + render( + + + , + ); + expect(test.views[0]?.dispose).toHaveBeenCalled(); + const current = test.views.at(-1); + if (!current) throw new Error("Reader did not mount"); + expect(current.refresh).toHaveBeenCalledOnce(); + act(() => current.update({ targetStatus })); + expect(navigation.complete).toHaveBeenCalledWith({ + status: "failed", + reason: targetStatus === "unavailable" ? "not-found" : "unavailable", + }); + expect(current.dispose).toHaveBeenCalled(); + expect(screen.getByRole("alert")).toHaveTextContent("could not be opened"); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Retry message" })); + expect(test.props.onRetry).toHaveBeenCalledOnce(); + }, +); + +it("offers retry when allocating an exact reader fails", () => { + const test = setup(), + { navigation } = test.request("first"); + vi.mocked(test.session.thread).mockImplementation(() => { + throw new Error("Access revoked"); + }); + render(); + expect(navigation.complete).toHaveBeenCalledWith({ + status: "failed", + reason: "unavailable", + }); + expect(screen.getByRole("button", { name: "Retry message" })).toBeVisible(); +}); + +it("disposes a superseded reader and ignores late results, then disposes on unmount", () => { + const test = setup(), + first = test.request("first"), + next = test.request("next"); + const mounted = render( + , + ); + const old = test.views.at(-1); + if (!old) throw new Error("Reader did not mount"); + mounted.rerender( + , + ); + expect(old.dispose).toHaveBeenCalled(); + act(() => old.update({ targetStatus: "unavailable" })); + expect(first.navigation.complete).not.toHaveBeenCalled(); + expect(next.navigation.complete).not.toHaveBeenCalled(); + expect(screen.getByRole("status")).toHaveTextContent( + "Loading selected message", + ); + mounted.unmount(); + expect(test.views.at(-1)?.dispose).toHaveBeenCalled(); +}); + +it("keeps a verified exact target readable if unrelated thread context fails", async () => { + const test = setup(), + { navigation, controller } = test.request("first"); + render(); + const current = test.views.at(-1); + if (!current) throw new Error("Reader did not mount"); + act(() => + current.update({ + status: "error", + error: "Thread context failed", + targetStatus: "ready", + target: { + id: "target", + channelId: "channel", + authorId: "author", + content: "Selected reply", + createdAt: 1, + mentions: [], + participants: [], + attachments: [], + reactions: [], + replyCount: 0, + }, + }), + ); + expect(screen.getByText("Selected reply")).toBeVisible(); + expect(navigation.complete).not.toHaveBeenCalled(); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Back to latest" })); + expect(test.props.onLatest).toHaveBeenCalledOnce(); + act(() => controller.abort()); + expect(current.dispose).toHaveBeenCalled(); + expect(screen.queryByText("Selected reply")).not.toBeInTheDocument(); +}); + +it("shows removal and retry after an opened navigation can no longer fail", async () => { + const test = setup(), + { navigation } = test.request("opened"); + vi.mocked(navigation.complete).mockReturnValue(false); + render(); + const current = test.views.at(-1); + if (!current) throw new Error("Reader did not mount"); + act(() => current.update({ targetStatus: "unavailable" })); + expect(navigation.signal.aborted).toBe(false); + expect(screen.getByRole("alert")).toHaveTextContent("could not be opened"); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Retry message" })); + expect(test.props.onRetry).toHaveBeenCalledOnce(); +}); diff --git a/src/features/sessions/SessionMessageTarget.tsx b/src/features/sessions/SessionMessageTarget.tsx new file mode 100644 index 00000000..525e4779 --- /dev/null +++ b/src/features/sessions/SessionMessageTarget.tsx @@ -0,0 +1,170 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { ConversationExtensions } from "../conversation/contracts"; +import type { PageNavigation } from "../navigation/service"; +import type { RelaySession } from "../relay/session"; +import type { ThreadView } from "../relay/threads"; +import { useRowProfiles } from "../relay/react"; +import { MessageRow } from "../messages/MessageRow"; +import { useMessageReveal } from "../messages/use-message-reveal"; +import { useReading } from "../messages/use-reading"; +import messages from "../messages/Messages.module.css"; +import styles from "./Sessions.module.css"; + +type Props = { + session: RelaySession; + scope: string; + channelId: string; + messageId: string; + navigation: PageNavigation; + extensions?: ConversationExtensions | undefined; + onOpenLink(url: string): boolean; + canOpenLink?: ((target: string) => boolean) | undefined; + onLatest(): void; + onRetry(): void; +}; + +/** An isolated verified target is not a contiguous page of session history. */ +export function SessionMessageTarget(props: Props) { + const { session, channelId, messageId, navigation } = props; + const [failed, setFailed] = useState(); + const [owned, setOwned] = useState<{ + request: PageNavigation; + view: ThreadView; + }>(); + useEffect(() => { + if (navigation.signal.aborted) return; + try { + const view = session.thread(channelId, messageId, { exact: true }); + const cancel = () => { + view.dispose(); + setOwned(undefined); + }; + setOwned({ request: navigation, view }); + navigation.signal.addEventListener("abort", cancel, { once: true }); + void view.refresh(); + return () => { + navigation.signal.removeEventListener("abort", cancel); + view.dispose(); + }; + } catch { + setFailed(navigation); + navigation.complete({ status: "failed", reason: "unavailable" }); + } + }, [session, channelId, messageId, navigation]); + return ( +
+
+ Selected message + +
+ {failed === navigation || navigation.signal.aborted ? ( + + ) : owned?.request === navigation ? ( + + ) : ( +

+ Loading selected message… +

+ )} +
+ ); +} + +function SelectedMessage({ + session, + scope, + channelId, + messageId, + navigation, + extensions, + onOpenLink, + canOpenLink, + view, + onRetry, +}: Props & { view: ThreadView }) { + const snapshot = useSyncExternalStore( + view.subscribe, + view.snapshot, + view.snapshot, + ); + const target = + snapshot.targetStatus === "ready" ? snapshot.target : undefined; + const rows = useMemo(() => (target ? [target] : []), [target]); + const profiles = useRowProfiles(session.profiles, rows); + const scroller = useRef(null); + const settled = useRef(false); + const complete = useCallback( + () => navigation.complete({ status: "opened" }), + [navigation], + ); + useMessageReveal({ + scroller, + settled, + messageId, + signal: navigation.signal, + ready: !!target, + complete, + }); + useReading({ session, channelId, scroller, settled }); + useEffect(() => { + if (snapshot.targetStatus === "unavailable") + navigation.complete({ status: "failed", reason: "not-found" }); + else if (snapshot.targetStatus === "error") + navigation.complete({ status: "failed", reason: "unavailable" }); + }, [navigation, snapshot.targetStatus]); + useEffect(() => { + if (target) + void session.profiles + .ensure([target.authorId, ...target.mentions], "background") + .catch(() => {}); + }, [session, target]); + return ( +
+ {target ? ( + + ) : snapshot.targetStatus === "unavailable" || + snapshot.targetStatus === "error" ? ( + + ) : ( +

Loading selected message…

+ )} +
+ ); +} + +function UnavailableMessage({ onRetry }: Pick) { + return ( +
+

The selected message could not be opened.

+ +
+ ); +} diff --git a/src/features/sessions/SessionPresentation.tsx b/src/features/sessions/SessionPresentation.tsx new file mode 100644 index 00000000..4d4e4ab3 --- /dev/null +++ b/src/features/sessions/SessionPresentation.tsx @@ -0,0 +1,58 @@ +import type { ReactNode, RefObject } from "react"; +import { Hash } from "lucide-react"; +import type { ChannelSummary } from "../relay/contracts"; +import styles from "./Sessions.module.css"; + +export function NewSessionView({ + children, + parentName, +}: { + children: ReactNode; + parentName?: string | undefined; +}) { + return ( +
+ +
+
{children}
+
+
+ ); +} + +/** Keep session messages and their composer in the same column as a new draft. */ +export function SessionColumn({ + children, + enabled = true, +}: { + children: ReactNode; + enabled?: boolean; +}) { + return enabled ?
{children}
: children; +} + +export function SessionHeading({ + channel, + headingRef, + children, +}: { + channel: Pick; + parentName?: string | undefined; + headingRef?: RefObject | undefined; + children?: ReactNode; +}) { + return ( +
+
+
+ {channel.archived ? Archived : children} +
+ ); +} diff --git a/src/features/sessions/Sessions.module.css b/src/features/sessions/Sessions.module.css new file mode 100644 index 00000000..e2d2ccfc --- /dev/null +++ b/src/features/sessions/Sessions.module.css @@ -0,0 +1,199 @@ +.work { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; +} +.workHeader { + min-height: 58px; + padding: var(--space-4) var(--space-panel-inset); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + flex-shrink: 0; +} +.workTitle { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} +.workTitle h2 { + margin: 0; + font-size: inherit; + font-weight: var(--type-weight-medium); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.timeline { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.timeline > * { + flex: 1; + min-height: 0; +} +.composer { + padding: var(--space-3) var(--space-6); +} +.composer > p { + color: var(--text-muted); + font-size: var(--text-caption); + padding: var(--space-2) var(--space-1) 0; +} +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + padding: var(--space-8); + height: 100%; + text-align: center; +} +.empty p { + color: var(--text-muted); + max-width: 420px; +} +.empty h1, +.empty h2 { + font-size: var(--text-heading); + font-weight: var(--type-weight-medium); +} +@media (max-width: 720px) { + .composer { + padding: var(--space-2); + } +} + +.availability { + color: var(--text-muted); + font-size: var(--text-caption); + line-height: 1.6; + margin: var(--space-3) var(--space-4) 0; +} + +.start { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-4) 0; + overflow-y: auto; +} + +.startContent, +.column { + width: 55%; +} + +.startContent { + margin-top: auto; +} + +.column { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + margin-inline: auto; + padding-bottom: var(--space-4); +} + +@media (max-width: 760px) { + .startContent { + width: 100%; + } + + .column { + width: calc(100% - 2 * var(--space-2)); + padding-bottom: var(--space-2); + } + + .start { + padding: var(--space-2); + } +} + +.agentTrigger { + display: inline-flex; + align-items: center; + gap: var(--space-1h); + max-width: min(240px, 45vw); + height: 32px; + padding: var(--space-chip-inset) var(--space-2) var(--space-chip-inset) + var(--space-chip-inset); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-muted); + cursor: pointer; +} +.agentTrigger:hover, +.agentTrigger[data-popup-open] { + background: var(--surface-hover); +} +.agentTrigger:disabled { + opacity: 0.5; + cursor: default; +} +.agentTrigger .agentAvatar, +.agentOption .agentAvatar { + width: 24px; + height: 24px; + border-radius: var(--radius-chip); + font-size: var(--text-caption); +} +.agentPositioner { + z-index: 50; +} +.agentPositioner .agentMenu { + position: relative; + max-width: min(340px, var(--available-width)); + max-height: min(360px, var(--available-height)); + font-size: var(--text-body-sm); + outline: none; +} +.agentOption { + outline: none; +} +.agentOption[data-highlighted] { + background: var(--completion-highlight); +} +.agentCheck { + margin-left: auto; + padding-left: var(--space-2); +} + +.agentName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-caption); +} +.agentTrigger > svg, +.agentTrigger .agentAvatar { + flex-shrink: 0; +} + +.timeline > .targetNavigation { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-3) var(--space-panel-inset); + color: var(--text-muted); + font-size: var(--text-caption); +} +.targetNavigation button { + cursor: pointer; +} diff --git a/src/features/sessions/metadata.ts b/src/features/sessions/metadata.ts new file mode 100644 index 00000000..4ad01e7f --- /dev/null +++ b/src/features/sessions/metadata.ts @@ -0,0 +1,16 @@ +/** Ordinary channel metadata describes presentation, never access authority. */ +export const SESSION_CHANNEL_DESCRIPTION = "Buzz session (buzz.sessions/v1)"; +const parentPrefix = `${SESSION_CHANNEL_DESCRIPTION}\nparent:`; +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +export function sessionDescription(parentId?: string): string { + return parentId ? `${parentPrefix}${parentId}` : SESSION_CHANNEL_DESCRIPTION; +} +export function sessionMetadata( + description: string | undefined, +): { parentId?: string } | undefined { + if (description === SESSION_CHANNEL_DESCRIPTION) return {}; + if (!description?.startsWith(parentPrefix)) return; + const parentId = description.slice(parentPrefix.length); + return uuid.test(parentId) ? { parentId } : undefined; +} diff --git a/src/features/sessions/recipients.test.ts b/src/features/sessions/recipients.test.ts new file mode 100644 index 00000000..778ebd20 --- /dev/null +++ b/src/features/sessions/recipients.test.ts @@ -0,0 +1,102 @@ +import { expect, it } from "vitest"; +import { sessionRecipients } from "./recipients"; +import type { ChannelSummary, Profile } from "../relay/contracts"; +const viewer = "a".repeat(64), + agent = "b".repeat(64), + other = "c".repeat(64); +const library = { + status: "ready" as const, + definitions: [], + identities: [{ pubkey: agent, name: "Agent" }], +}; +const channel: ChannelSummary = { + id: "session", + name: "Work", + channelType: "session", + members: [viewer, agent], +}; +const profiles = new Map(); +it("uses only roster members and deduplicates a known sole agent", () => { + expect( + sessionRecipients( + { ...channel, members: [viewer, agent, agent] }, + profiles, + library, + viewer, + [], + ), + ).toEqual([agent]); + expect( + sessionRecipients( + { ...channel, members: [viewer] }, + profiles, + library, + viewer, + [], + ), + ).toEqual([]); +}); +it("does not confuse a missing profile with a human or guess from display names", () => { + const multiple = { ...channel, members: [viewer, agent, other] }; + expect(() => + sessionRecipients(multiple, profiles, library, viewer, []), + ).toThrow(/loading/); + expect( + sessionRecipients( + multiple, + new Map([[other, { name: "Agent" }]]), + library, + viewer, + [], + ), + ).toEqual([agent]); + expect(() => + sessionRecipients( + multiple, + new Map([[other, { name: "Agent", isAgent: true }]]), + library, + viewer, + [], + ), + ).toThrow(/multiple/); +}); +it("preserves explicit recipients and leaves ordinary channels unchanged", () => { + expect( + sessionRecipients(channel, profiles, library, viewer, [other]), + ).toEqual([other]); + expect( + sessionRecipients( + { ...channel, channelType: "stream" }, + profiles, + library, + viewer, + [], + ), + ).toEqual([]); +}); + +it("does not mistake an agent-library refresh for evidence of a sole agent", () => { + const multiple = { ...channel, members: [viewer, agent, other] }; + const knownProfiles = new Map([ + [agent, { name: "Local agent" }], + [other, { name: "Shared agent", isAgent: true }], + ]); + expect(() => + sessionRecipients( + multiple, + knownProfiles, + { ...library, status: "loading", identities: [] }, + viewer, + [], + ), + ).toThrow(/loading/); + expect(() => + sessionRecipients( + multiple, + knownProfiles, + { ...library, status: "loading" }, + viewer, + [], + ), + ).toThrow(/multiple/); +}); diff --git a/src/features/sessions/recipients.ts b/src/features/sessions/recipients.ts new file mode 100644 index 00000000..b922ac7b --- /dev/null +++ b/src/features/sessions/recipients.ts @@ -0,0 +1,42 @@ +import type { AgentLibrarySnapshot } from "../agents/library"; +import type { ChannelSummary, Profile } from "../relay/contracts"; + +/** Classification never grants access: candidates must be in the current relay roster. */ +export function sessionAgents( + channel: ChannelSummary | undefined, + profiles: ReadonlyMap, + library: AgentLibrarySnapshot, + viewer: string | undefined, +): readonly string[] | undefined { + if (!channel?.members) return; + const known = new Set(library.identities.map((agent) => agent.pubkey)); + const members = [...new Set(channel.members)].filter((key) => key !== viewer); + if ( + library.status !== "ready" && + members.some((key) => !known.has(key) && !profiles.get(key)?.isAgent) + ) + return; + // Missing profiles can hide a second agent; never guess a sole recipient. + if (members.some((key) => !known.has(key) && !profiles.has(key))) return; + return members.filter((key) => known.has(key) || profiles.get(key)?.isAgent); +} + +export function sessionRecipients( + channel: ChannelSummary | undefined, + profiles: ReadonlyMap, + library: AgentLibrarySnapshot, + viewer: string | undefined, + explicit: readonly string[], +): readonly string[] { + if (channel?.channelType !== "session" || explicit.length) return explicit; + const agents = sessionAgents(channel, profiles, library, viewer); + if (!agents) + throw new Error( + "Session participants are still loading. Retry or @mention an agent.", + ); + if (agents.length > 1) + throw new Error( + "There are multiple agents in this session. @mention who should respond.", + ); + return agents; +} diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 3a265bf2..bb77990c 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -28,6 +28,7 @@ export const test = base.extend({ readState: [false, { option: true }], threadUnread: [false, { option: true }], exactMessages: [false, { option: true }], + sessionChannels: [[], { option: true }], sidebarUnread: [false, { option: true }], savedSidebar: [false, { option: true }], expectedPageFailure: [false, { option: true }], @@ -49,6 +50,7 @@ export const test = base.extend({ readState, threadUnread, exactMessages, + sessionChannels, sidebarUnread, savedSidebar, expectedPageFailure, @@ -391,6 +393,13 @@ export const test = base.extend({ ["d", id], ["name", id === "alpha" ? "Alpha" : id === "beta" ? "Beta" : id], ...(dmIds.includes(id) ? [["t", "dm"], ["hidden"]] : []), + ...(sessionChannels.includes(id) + ? [ + ["t", "stream"], + ["private"], + ["about", "Buzz session (buzz.sessions/v1)"], + ] + : []), ...(hiddenChannels.has(id) ? [["hidden"]] : []), ]), ); @@ -471,6 +480,13 @@ export const test = base.extend({ ([key, value]) => key === "e" && filter["#e"].includes(value), ), ) + .filter( + (event) => + filter.until === undefined || + event.created_at < filter.until || + (event.created_at === filter.until && + event.id > filter.before_id), + ) .toSorted( (a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id), ) @@ -533,6 +549,13 @@ export const test = base.extend({ ? [...threadReplies.values()].flat() : []), ]) + .filter( + (event) => + filter.until === undefined || + event.created_at < filter.until || + (event.created_at === filter.until && + event.id > filter.before_id), + ) .toSorted( (a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id), ) @@ -657,11 +680,24 @@ export const test = base.extend({ return send(response, { viewer, relayAuthor: getPublicKey(relayKey), - writeKinds: [9], + writeKinds: sessionChannels.length ? [9, 9007] : [9], relayUrl: JSON.parse(fixtureAliases)[community], live: true, }); } + if (sessionChannels.length && route === "sign") { + expect(body.kind).toBe(9); + return send(response, finalizeEvent(body, userKey)); + } + if (sessionChannels.length && route === "publish") { + expect(verifyEvent(body)).toBe(true); + expect(body.pubkey).toBe(viewer); + expect(body.kind).toBe(9); + const channel = body.tags.find(([key]) => key === "h")?.[1]; + expect(sessionChannels).toContain(channel); + histories.get(`${community}/${channel}`).push(body); + return send(response, { accepted: true, event_id: body.id }); + } if (route === "stream") { // Match the production broker: WebKit can buffer trailing HTTP chunks. // Close-delimited SSE must deliver each append without a later write. @@ -708,7 +744,7 @@ export const test = base.extend({ .map((event) => [event.id, event]), ).values(), ]; - if (filter.until !== undefined) { + if (filter.until !== undefined && filter["#h"]?.length) { pending.push({ community, channel: filter["#h"][0], @@ -958,11 +994,11 @@ export const test = base.extend({ relay.publish("primary", event); return event; }, - append(community, channel, content, deliver = true, own = true) { + append(community, channel, content, deliver = true, own = true, root) { const history = histories.get(`${community}/${channel}`); const event = sign( 9, - [["h", channel]], + [["h", channel], ...(root ? [["e", root, "", "reply"]] : [])], content ?? `Live append ${history.length}`, own ? userKey : peerKey, (history.at(-1)?.created_at ?? 1700000900) + 1, diff --git a/tests/browser/layout.spec.mjs b/tests/browser/layout.spec.mjs index f99b6ad0..c544b979 100644 --- a/tests/browser/layout.spec.mjs +++ b/tests/browser/layout.spec.mjs @@ -642,7 +642,14 @@ 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", "Workflows"]; + const titles = [ + "Home", + "Messages", + "Projects", + "Agents", + "Sessions", + "Workflows", + ]; 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 }); @@ -685,6 +692,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde "Home", "Messages", "Agents", + "Sessions", "Workflows", ]); await projects.click(); @@ -699,6 +707,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde "Home", "Projects", "Agents", + "Sessions", "Workflows", ]); await channels.click(); @@ -708,6 +717,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde "Messages", "Projects", "Agents", + "Sessions", "Workflows", "Make it yoursSettings", ]); diff --git a/tests/browser/session-navigation.spec.mjs b/tests/browser/session-navigation.spec.mjs new file mode 100644 index 00000000..9588df7d --- /dev/null +++ b/tests/browser/session-navigation.spec.mjs @@ -0,0 +1,132 @@ +import { test, expect } from "./fixture.mjs"; +import { settle } from "./timeline.mjs"; + +// The production app's exact-reader -> DOM focus/visibility -> navigation +// completion boundary requires real layout and both browser engines. +test.use({ + pluginFixtures: true, + exactMessages: true, + sessionChannels: ["alpha"], + historyCounts: { alpha: 90, beta: 5 }, +}); +const target = (app, messageId, threadRootId) => ({ + version: 1, + kind: "conversation", + channelId: "alpha", + scope: { viewer: app.viewer, communityOrigin: "https://primary.example" }, + ...(messageId ? { messageId } : {}), + ...(threadRootId ? { threadRootId } : {}), +}); +const openTarget = (page, value) => + page.evaluate((target) => window.fixtureNavigation.open(target), value); +const history = (page) => + page.getByRole("region", { name: "Channel message history", exact: true }); +const selected = (page) => + page.getByRole("region", { name: "Selected session message", exact: true }); + +test("older session root and reply links fetch and reveal inline, then sending returns to latest", async ({ + page, + app, +}) => { + await page.goto(app.origin); + await expect + .poll(() => + page.evaluate(() => window.fixtureNavigation?.snapshot().status), + ) + .toBe("opened"); + expect(await openTarget(page, target(app))).toEqual({ status: "opened" }); + await expect( + history(page).locator(`[data-message-id="${app.exact.root.id}"]`), + ).toHaveCount(0); + for (const [mode, id] of [ + ["cold root", app.exact.root.id], + ["cold reply", app.exact.target.id], + ["warm reply", app.exact.target.id], + ]) { + const start = performance.now(); + expect( + await openTarget( + page, + target(app, id, mode === "cold root" ? id : app.exact.root.id), + ), + ).toEqual({ status: "opened" }); + app.report.measurements.push({ + mode, + clickToOpenedMs: performance.now() - start, + }); + const row = selected(page).locator(`[data-message-id="${id}"]`); + await expect(row).toBeFocused(); + await expect(row).toBeInViewport(); + await expect(selected(page).locator("[data-message-id]")).toHaveCount(1); + await expect( + page.getByRole("complementary", { name: "Thread", exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole("textbox", { name: "Reply to thread", exact: true }), + ).toHaveCount(0); + expect( + app.report.queries.some(({ filter }) => filter.ids?.includes(id)), + ).toBe(true); + if (id === app.exact.target.id) + await expect(row).toContainText("Exact reply edited"); + if (mode !== "warm reply") { + await page + .getByRole("button", { name: "Back to latest", exact: true }) + .click(); + await expect(history(page)).toBeVisible(); + await expect(selected(page)).toHaveCount(0); + } + } + const composer = page.getByRole("textbox", { + name: "Message this session", + exact: true, + }); + await composer.fill("Continue the session from an older link"); + await composer.press("Enter"); + await expect( + history(page).getByText("Continue the session from an older link", { + exact: true, + }), + ).toBeInViewport(); + await expect(selected(page)).toHaveCount(0); +}); + +test("a loaded session reply reveals in the existing timeline without an exact lookup", async ({ + page, + app, +}) => { + await page.goto(app.origin); + await expect + .poll(() => + page.evaluate(() => window.fixtureNavigation?.snapshot().status), + ) + .toBe("opened"); + expect(await openTarget(page, target(app))).toEqual({ status: "opened" }); + await expect(history(page)).toBeVisible(); + await settle(page); + const reply = app.append( + "primary", + "alpha", + "Loaded session reply", + true, + true, + app.exact.root.id, + ); + await expect( + history(page).locator(`[data-message-id="${reply.id}"]`), + ).toBeVisible(); + await settle(page); + const reads = app.report.queries.filter(({ filter }) => + filter.ids?.includes(reply.id), + ).length; + expect( + await openTarget(page, target(app, reply.id, app.exact.root.id)), + ).toEqual({ status: "opened" }); + await expect( + history(page).locator(`[data-message-id="${reply.id}"]`), + ).toBeFocused(); + await expect(selected(page)).toHaveCount(0); + expect( + app.report.queries.filter(({ filter }) => filter.ids?.includes(reply.id)), + ).toHaveLength(reads); +});