From 1d3c225d19861faa10b85ddaa10cebdb9da82e14 Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Wed, 4 Nov 2026 14:38:28 -0800 Subject: [PATCH 1/3] feat(conversation): let plugin rows manage messages without visiting a channel Add conversation.ui.Conversation, a host-owned scope for a page's own ui.Message rows and ui.Composer in one channel. It reuses the existing non-visiting ConversationChannel host mode, so rows get native edit, confirmed delete with recovery and retry, a per-scope edit target and host media review, while mounting never marks read, follows or holds the channel roster. Fixes NEXUS-4193 Co-authored-by: Claude Opus 5.5 Signed-off-by: Kyle Zemek --- docs/plugin-architecture.md | 17 +- .../EmbeddedConversation.test.tsx | 405 ++++++++++++++++++ src/features/conversation/EmbeddedThread.tsx | 69 ++- src/features/conversation/service.test.tsx | 1 + src/features/conversation/service.tsx | 13 +- 5 files changed, 494 insertions(+), 11 deletions(-) create mode 100644 src/features/conversation/EmbeddedConversation.test.tsx diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index b76db3ca4e..310ea0ff37 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -2022,7 +2022,7 @@ pinch, Ctrl/⌘-scroll, drag, double-click and keyboard and offers Copy code. Invalid source shows Pele's error with the source. The conversation preview exposes top-level `registerTool`, `registerCompletion` and `registerInline` -methods and stable `conversation.ui.Composer` / `.Message` / `.Thread` components, plus `.Tabs` while a plugin provides it. Generated +methods and stable `conversation.ui.Composer` / `.Message` / `.Thread` / `.Conversation` components, plus `.Tabs` while a plugin provides it. Generated type-only `@buzz/author` declarations are exercised by a source-only external consumer fixture in `tests/fixtures/conversation-consumer`; it is built and installed only in the browser test's temporary profile. @@ -2052,6 +2052,21 @@ that visit lasts across threads of the same channel. The page holds no second message reader, cache or outbox. Exact message links still navigate to Channels rather than the page's route. +`conversation.ui.Conversation({ session, scope, channelId, channelName?, threadId?, children })` +gives a page's own `ui.Message` rows and `ui.Composer` for one channel the host +behavior Channels rows have, without showing a thread or visiting the channel: +mounting it never marks anything read, follows a thread or holds the channel's +roster. `children(interactions)` receives the host's `onOpenLink`, `canOpenLink` +and `onOpenMediaReview` for those rows. Inside it, rows offer Edit and Delete +under the usual permissions; Edit fills the `ui.Composer` in the same scope +(pass the rows it may edit as `editMessages`), and a row has no Edit without one. +Delete asks for confirmation, and a failed edit or deletion shows a notice to +retry or discard it before `children`. Media review and registered panels open +over the page, one at a time per scope, and stop opening once its session is +no longer the ready one. Use one scope per channel or card, so each card's +edits and recovery stay its own; `threadId` keeps panels opened from a card in +that thread. Writes, mark read/unread and follow remain explicit menu actions. + `conversation.ui.Tabs({ id, session, scope, label, icon, children })` makes the page's content the first main tab of the host's conversation tab workspace. `children(open, close, isOpen)` renders that tab; `open({ channelId, threadId?, messageId? }, { keep? })` diff --git a/src/features/conversation/EmbeddedConversation.test.tsx b/src/features/conversation/EmbeddedConversation.test.tsx new file mode 100644 index 0000000000..75ce268acf --- /dev/null +++ b/src/features/conversation/EmbeddedConversation.test.tsx @@ -0,0 +1,405 @@ +// @vitest-environment jsdom +import { Context } from "@deepseek-ai/cordis"; +import "@testing-library/jest-dom/vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, assert, expect, it, vi } from "vitest"; +import { composerDOMFixture } from "../messages/composer-testing"; +import type { ComposerInputElement } from "../messages/composer-dom"; +import { PublishRejected } from "../relay/outbox"; +import type { RelayEvent } from "../relay/events"; +import { useChannelWindow } from "../relay/react"; +import { createRelaySession } from "../relay/session"; +import type { RelaySession } from "../relay/session"; +import { + bounds, + keypair, + message, + metadata, + roster, + signed, +} from "../relay/testing"; +import { ConversationPresentation } from "./ConversationPresentation"; +import { EmbeddedConversation } from "./EmbeddedThread"; +import { PluginRuntime } from "../../plugins/runtime"; +import { ConversationService } from "./service"; + +// The host's review viewer has its own suites; this proves which card's review opens. +vi.mock("../messages/MediaReviewViewer", () => ({ + MediaReviewViewer: (props: { + channelId: string; + messageId: string; + close(): void; + }) => ( +
+

{`${props.channelId}:${props.messageId}`}

+ +
+ ), +})); + +composerDOMFixture(); +const owners: ReturnType[] = []; +const runtimes: Context[] = []; +afterEach(async () => { + cleanup(); + vi.unstubAllGlobals(); + for (const owner of owners.splice(0)) owner.dispose(); + for (const root of runtimes.splice(0)) await root.fiber.dispose(); +}); +function deferred() { + let resolve!: () => void, reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +async function fixture() { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + HTMLElement.prototype.scrollIntoView = vi.fn(); + const relay = keypair(), + viewer = keypair(); + const posts = { + alpha: message(viewer, "alpha", "Alpha note", 1700000000), + beta: message(viewer, "beta", "Beta note", 1700000001), + }; + const publications: { + event: RelayEvent; + result: ReturnType; + }[] = []; + const owner = createRelaySession( + { + viewer: viewer.pubkey, + relayAuthor: relay.pubkey, + media: () => undefined, + async query(filters) { + const filter = filters[0]; + assert.exists(filter); + if (filter.kinds?.includes(39000) || filter.kinds?.includes(39002)) + return (["alpha", "beta"] as const).flatMap((id) => [ + metadata(relay, id, id === "alpha" ? "Alpha" : "Beta"), + roster(relay, id, [viewer.pubkey]), + ]); + if (filter.kinds?.includes(9)) + return (["alpha", "beta"] as const) + .filter((id) => !filter["#h"] || filter["#h"].includes(id)) + .flatMap((id) => [ + posts[id], + bounds(relay, id, "head", { has_more: false, next_cursor: null }), + ]); + return []; + }, + writer: { + kinds: [9, 40003, 5], + sign: async (template) => signed(viewer, template), + publish(event) { + const result = deferred(); + publications.push({ event, result }); + return result.promise; + }, + }, + }, + { + readStateFloor: () => 0, + outboxStorage: { load: () => [], save() {} }, + readPublisherLock: async (_signal, work) => work(), + }, + ); + owners.push(owner); + const live = owner.session; + live.channels.ensureList(); + await waitFor(() => expect(live.channels.list().status).toBe("ready")); + for (const id of ["alpha", "beta"]) live.channels.ensure(id); + await waitFor(() => { + expect(live.channels.window("alpha").rows).toHaveLength(1); + expect(live.channels.window("beta").rows).toHaveLength(1); + }); + // The session is frozen, so read-state entry points are observed through delegates. + const reads = { + enterChannel: vi.fn(live.unread.enterChannel), + leaveChannel: vi.fn(live.unread.leaveChannel), + follow: vi.fn(live.unread.follow), + markMessageRead: vi.fn(live.unread.markMessageRead), + markChannelRead: vi.fn(live.unread.markChannelRead), + }; + const session: RelaySession = { + ...live, + unread: { ...live.unread, ...reads }, + }; + const root = new Context(); + new PluginRuntime(root, async () => ({ apply() {} })); + runtimes.push(root); + const service = new ConversationService(root); + const scope = `wss://fixture.test:${viewer.pubkey}`; + let connection: { status: string; session: RelaySession; scope: string } = { + status: "ready", + session, + scope, + }; + const noPanels: never[] = []; + const host = { + panels: { + snapshot: () => noPanels, + subscribe: () => () => {}, + resolve: () => undefined, + }, + relay: { snapshot: () => connection }, + navigation: { open: vi.fn() }, + } as unknown as Context; + const { Message, Composer } = service.ui; + + /** A fictional overview: one card per channel, rows and composer composed by the page. */ + function Card({ + channelId, + scoped = true, + }: { + channelId: "alpha" | "beta"; + scoped?: boolean; + }) { + const { rows } = useChannelWindow(session.channels, channelId); + const content = ( + onOpenMediaReview?: (id: string, attachment: never, at: number) => void, + ) => ( + <> + {rows.map((row) => ( + undefined} + onOpenLink={() => false} + day={false} + retry={undefined} + /> + ))} + {onOpenMediaReview && rows[0] && ( + + )} + + + ); + // The scope renders recovery notices before its children, so the card frames both. + return ( +
+ {scoped ? ( + + {(interactions) => content(interactions.onOpenMediaReview)} + + ) : ( + content() + )} +
+ ); + } + function Overview({ + presented = true, + scoped = true, + }: { + presented?: boolean; + scoped?: boolean; + }) { + return ( + + + + + ); + } + return { + session, + service, + posts, + reads, + publications, + Overview, + loseAccess() { + connection = { ...connection, status: "connecting" }; + }, + }; +} + +const card = (id: string) => + within(screen.getByRole("region", { name: `Card ${id}` })); +async function openMenu(id: string) { + fireEvent.click( + card(id).getByRole("button", { name: "More message actions" }), + ); + return within(await screen.findByRole("menu")); +} +function editor(id: string) { + return card(id).getByRole("textbox", { + name: "Edit message", + }); +} + +it("adds native edit and delete to page rows without visiting, reading or following", async () => { + const h = await fixture(); + const view = render(); + const bare = await openMenu("alpha"); + expect(bare.queryByRole("menuitem", { name: "Edit message" })).toBeNull(); + expect(bare.queryByRole("menuitem", { name: "Delete message" })).toBeNull(); + fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" }); + view.rerender(); + for (const id of ["alpha", "beta"]) { + const menu = await openMenu(id); + expect(menu.getByRole("menuitem", { name: "Edit message" })).toBeVisible(); + expect( + menu.getByRole("menuitem", { name: "Delete message" }), + ).toBeVisible(); + fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + } + view.rerender(); + view.rerender(); + view.unmount(); + await act(async () => {}); + for (const spy of Object.values(h.reads)) expect(spy).not.toHaveBeenCalled(); + expect(h.publications).toHaveLength(0); +}); + +it("edits into the card's own composer and publishes to that card's channel", async () => { + const h = await fixture(); + render(); + fireEvent.click( + (await openMenu("beta")).getByRole("menuitem", { name: "Edit message" }), + ); + await waitFor(() => expect(editor("beta")).toHaveValue("Beta note")); + expect( + card("alpha").queryByRole("textbox", { name: "Edit message" }), + ).toBeNull(); + const field = editor("beta"); + act(() => { + field.focus(); + field.value = "Beta corrected"; + field.setSelectionRange(14, 14); + }); + fireEvent.input(field); + const form = field.closest("form"); + assert.exists(form); + fireEvent.submit(form); + await waitFor(() => expect(h.publications).toHaveLength(1)); + const edit = h.publications[0]; + assert.exists(edit); + expect(edit.event.kind).toBe(40003); + expect(edit.event.tags).toContainEqual(["h", "beta"]); + expect(edit.event.tags).toContainEqual(["e", h.posts.beta.id]); + await act(async () => edit.result.resolve()); + await waitFor(() => + expect(h.session.channels.window("beta").rows[0]?.content).toBe( + "Beta corrected", + ), + ); + expect(h.session.channels.window("alpha").rows[0]?.content).toBe( + "Alpha note", + ); + for (const spy of Object.values(h.reads)) expect(spy).not.toHaveBeenCalled(); +}); + +it("confirms, cancels, recovers and retries a card's deletion", async () => { + const h = await fixture(); + render(); + fireEvent.click( + (await openMenu("alpha")).getByRole("menuitem", { name: "Delete message" }), + ); + const confirm = await screen.findByRole("alertdialog", { + name: "Delete message?", + }); + fireEvent.click(within(confirm).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); + expect(h.publications).toHaveLength(0); + + fireEvent.click( + (await openMenu("alpha")).getByRole("menuitem", { name: "Delete message" }), + ); + fireEvent.click( + within(await screen.findByRole("alertdialog")).getByRole("button", { + name: "Delete", + }), + ); + await waitFor(() => expect(h.publications).toHaveLength(1)); + const first = h.publications[0]; + assert.exists(first); + expect(first.event.kind).toBe(5); + expect(first.event.tags).toContainEqual(["e", h.posts.alpha.id]); + await act(async () => + first.result.reject(new PublishRejected("Deletion refused")), + ); + fireEvent.click( + within(await screen.findByRole("alertdialog")).getByText("Close"), + ); + // Recovery belongs to the card's channel, not every card on the page. + expect( + await card("alpha").findByText("Message deletion: Deletion refused"), + ).toBeVisible(); + expect(card("beta").queryByText(/Message deletion/)).toBeNull(); + fireEvent.click( + card("alpha").getByRole("button", { name: "Retry message update" }), + ); + await waitFor(() => expect(h.publications).toHaveLength(2)); + expect(h.publications[1]?.event.id).toBe(first.event.id); + await act(async () => h.publications[1]?.result.resolve()); + await waitFor(() => + expect(card("alpha").queryByText("Alpha note")).toBeNull(), + ); + expect(card("beta").getByText("Beta note")).toBeVisible(); + for (const spy of Object.values(h.reads)) expect(spy).not.toHaveBeenCalled(); +}); + +it("opens one card's media review over the page and ignores reviews after access loss", async () => { + const h = await fixture(); + render(); + const opener = card("beta").getByRole("button", { name: "Review media" }); + opener.focus(); + fireEvent.click(opener); + expect( + screen.getByRole("region", { name: "Media review" }), + ).toHaveTextContent(`beta:${h.posts.beta.id}`); + fireEvent.click(screen.getByRole("button", { name: "Close review" })); + expect(screen.queryByRole("region", { name: "Media review" })).toBeNull(); + h.loseAccess(); + fireEvent.click(card("beta").getByRole("button", { name: "Review media" })); + expect(screen.queryByRole("region", { name: "Media review" })).toBeNull(); + for (const spy of Object.values(h.reads)) expect(spy).not.toHaveBeenCalled(); +}); diff --git a/src/features/conversation/EmbeddedThread.tsx b/src/features/conversation/EmbeddedThread.tsx index e1278ffa9d..3f56b882b0 100644 --- a/src/features/conversation/EmbeddedThread.tsx +++ b/src/features/conversation/EmbeddedThread.tsx @@ -1,29 +1,79 @@ -import { useSyncExternalStore } from "react"; +import { useSyncExternalStore, type ReactNode } from "react"; import type { ThreadPanelProps } from "../messages/ThreadPanel"; import { messageViewKey } from "../messages/view-key"; -import { ConversationChannel, ConversationThread } from "./Conversation"; -import type { ConversationHostServices } from "./ConversationHost"; +import { + ConversationChannel, + ConversationThread, + useConversation, +} from "./Conversation"; +import type { + ConversationHostServices, + ConversationInteractions, +} from "./ConversationHost"; /** The host owns conversation behavior; the page owns its task and placement. */ export type EmbeddedThreadProps = Pick< ThreadPanelProps, "session" | "scope" | "channelId" | "channelName" | "messageId" >; -type Props = EmbeddedThreadProps & { +/** One channel's host behavior for a page's own rows and composer, without a visit. */ +export type EmbeddedConversationProps = Pick< + ThreadPanelProps, + "session" | "scope" | "channelId" +> & { + channelName?: string | undefined; + /** The thread a card shows; panels opened from it stay in that thread. */ + threadId?: string | undefined; + children(interactions: ConversationInteractions): ReactNode; +}; +type Hosted = { host: ConversationHostServices; extensions: NonNullable; }; /** Channel visits and recovery span threads; overlays belong to one thread. */ export function EmbeddedThread({ + messageId, + ...props +}: EmbeddedThreadProps & Hosted) { + return ( + + + + ); +} + +/** Rows and composer act as in Channels; nothing marks the channel read or follows it. */ +export function EmbeddedConversation({ + children, + ...props +}: EmbeddedConversationProps & Hosted) { + return ( + + {children} + + ); +} + +function Interactions({ + children, +}: Pick) { + const { onOpenLink, canOpenLink, onOpenMediaReview } = useConversation(); + return children({ onOpenLink, canOpenLink, onOpenMediaReview }); +} + +function HostedChannel({ session, scope, channelId, channelName, - messageId, + threadId, + active, host, extensions, -}: Props) { + children, +}: Omit & + Hosted & { active?: false; children: ReactNode }) { const { channels } = useSyncExternalStore( session.channels.subscribeList, session.channels.list, @@ -36,15 +86,16 @@ export function EmbeddedThread({ scope={scope} extensions={extensions} host={host} - threadId={messageId} + threadId={threadId} + active={active} channel={{ id: channelId, - name: channelName, + name: channelName ?? channel?.name ?? "", channelType: channel?.channelType, creator: channel?.creator, }} > - + {children} ); } diff --git a/src/features/conversation/service.test.tsx b/src/features/conversation/service.test.tsx index cbad6dc742..c4a7fdf7e6 100644 --- a/src/features/conversation/service.test.tsx +++ b/src/features/conversation/service.test.tsx @@ -49,6 +49,7 @@ it("registers both surfaces under the injecting plugin scope, removes and replac expect(conversation.ui.Composer).toBe(conversation.ui.Composer); expect(conversation.ui.Message).toBe(conversation.ui.Message); expect(conversation.ui.Thread).toBe(conversation.ui.Thread); + expect(conversation.ui.Conversation).toBe(conversation.ui.Conversation); // Plugins get the host's own labels; their output is tested at the owner. expect(conversation.format).toEqual({ itemTimestamp: formatItemTimestamp, diff --git a/src/features/conversation/service.tsx b/src/features/conversation/service.tsx index e7a5a96f47..f03b3266d0 100644 --- a/src/features/conversation/service.tsx +++ b/src/features/conversation/service.tsx @@ -1,6 +1,11 @@ // FOUNDATION: Shared conversation UI and plugin-owned tools/renderers; never another data owner. import type { ComponentType, ReactNode } from "react"; -import { EmbeddedThread, type EmbeddedThreadProps } from "./EmbeddedThread"; +import { + EmbeddedConversation, + EmbeddedThread, + type EmbeddedConversationProps, + type EmbeddedThreadProps, +} from "./EmbeddedThread"; import { Service, type Context } from "@deepseek-ai/cordis"; import { createContributions } from "../../plugins/contributions"; import { @@ -73,6 +78,9 @@ export type Conversation = { registerMessageAction(action: MessageAction): void; ui: { Thread: (props: EmbeddedThreadProps) => ReactNode; + /** One channel's message management, edit target and overlays for a page's + * own `Message` rows and `Composer`, without visiting the channel. */ + Conversation: (props: EmbeddedConversationProps) => ReactNode; Composer: (props: Omit) => ReactNode; Message: (props: Omit) => ReactNode; /** The page's content as the first tab, beside conversations it opens; @@ -222,6 +230,9 @@ export class ConversationService extends Service implements Conversation { Thread: (props: EmbeddedThreadProps) => ( ), + Conversation: (props: EmbeddedConversationProps) => ( + + ), Composer: (props: Omit) => ( ), From 1d721b8ca395d38d0f65c51a5e019ba7911ff0b1 Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Wed, 4 Nov 2026 14:58:17 -0800 Subject: [PATCH 2/3] test(conversation): cover the no-visit scope's roster hold and author consumer Observe the profile reservation an open channel makes, exercise ui.Conversation and its interactions in the packed author consumer fixture, and name the instance rather than the scope prop in the docs. Refs NEXUS-4193 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kyle Zemek --- docs/plugin-architecture.md | 17 +++---- .../EmbeddedConversation.test.tsx | 9 +++- .../fixtures/conversation-consumer/plugin.ts | 44 ++++++++++++------- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 310ea0ff37..b09e3197c8 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -2058,14 +2058,15 @@ behavior Channels rows have, without showing a thread or visiting the channel: mounting it never marks anything read, follows a thread or holds the channel's roster. `children(interactions)` receives the host's `onOpenLink`, `canOpenLink` and `onOpenMediaReview` for those rows. Inside it, rows offer Edit and Delete -under the usual permissions; Edit fills the `ui.Composer` in the same scope -(pass the rows it may edit as `editMessages`), and a row has no Edit without one. -Delete asks for confirmation, and a failed edit or deletion shows a notice to -retry or discard it before `children`. Media review and registered panels open -over the page, one at a time per scope, and stop opening once its session is -no longer the ready one. Use one scope per channel or card, so each card's -edits and recovery stay its own; `threadId` keeps panels opened from a card in -that thread. Writes, mark read/unread and follow remain explicit menu actions. +under the usual permissions; Edit fills the `ui.Composer` inside the same +`ui.Conversation` (pass the rows it may edit as `editMessages`), and a row has +no Edit without one. Delete asks for confirmation, and a failed edit or deletion +shows a notice to retry or discard it before `children`. Media review and +registered panels open over the page and stop opening once its session is no +longer the ready one. Render one `ui.Conversation` per channel or card, so each +card's edits and recovery stay its own; `threadId` keeps panels opened from a +card in that thread. Writes, mark read/unread and follow remain explicit menu +actions. `conversation.ui.Tabs({ id, session, scope, label, icon, children })` makes the page's content the first main tab of the host's conversation tab workspace. diff --git a/src/features/conversation/EmbeddedConversation.test.tsx b/src/features/conversation/EmbeddedConversation.test.tsx index 75ce268acf..0b96da6658 100644 --- a/src/features/conversation/EmbeddedConversation.test.tsx +++ b/src/features/conversation/EmbeddedConversation.test.tsx @@ -131,7 +131,7 @@ async function fixture() { expect(live.channels.window("alpha").rows).toHaveLength(1); expect(live.channels.window("beta").rows).toHaveLength(1); }); - // The session is frozen, so read-state entry points are observed through delegates. + // The session is frozen, so visit entry points are observed through delegates. const reads = { enterChannel: vi.fn(live.unread.enterChannel), leaveChannel: vi.fn(live.unread.leaveChannel), @@ -139,9 +139,14 @@ async function fixture() { markMessageRead: vi.fn(live.unread.markMessageRead), markChannelRead: vi.fn(live.unread.markChannelRead), }; + // An open channel reserves profile room for its roster; a no-visit scope holds none. + const reserve = vi.fn( + (members: number) => live.profiles.reserve?.(members) ?? (() => {}), + ); const session: RelaySession = { ...live, unread: { ...live.unread, ...reads }, + profiles: { ...live.profiles, reserve }, }; const root = new Context(); new PluginRuntime(root, async () => ({ apply() {} })); @@ -251,7 +256,7 @@ async function fixture() { session, service, posts, - reads, + reads: { ...reads, reserve }, publications, Overview, loseAccess() { diff --git a/tests/fixtures/conversation-consumer/plugin.ts b/tests/fixtures/conversation-consumer/plugin.ts index 1757347cf3..1b0e5fa023 100644 --- a/tests/fixtures/conversation-consumer/plugin.ts +++ b/tests/fixtures/conversation-consumer/plugin.ts @@ -98,26 +98,38 @@ export function apply(ctx: Context) { { type: "button", onClick: () => rerender(renders + 1) }, `Rerender consumer ${renders}`, ), - h( - "div", - { "aria-label": "Consumer messages" }, - window.rows.map((row) => - h(ui.ui.Message, { - key: row.id, - row, - profile: undefined, - media: session.media, - onOpenLink: () => false, - day: false, - retry: session.messages.retry, - }), - ), - ), - h(ui.ui.Composer, { + // The host's no-visit scope gives these rows its message actions. + h(ui.ui.Conversation, { session, scope: connection.scope ?? "disconnected", channelId, channelName, + children: (interactions) => + h( + React.Fragment, + null, + h( + "div", + { "aria-label": "Consumer messages" }, + window.rows.map((row) => + h(ui.ui.Message, { + key: row.id, + row, + profile: undefined, + media: session.media, + onOpenLink: interactions.onOpenLink, + day: false, + retry: session.messages.retry, + }), + ), + ), + h(ui.ui.Composer, { + session, + scope: connection.scope ?? "disconnected", + channelId, + channelName, + }), + ), }), ); } From 1394bdaaee251030c53512c029a1195f03bca1eb Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Wed, 4 Nov 2026 15:26:35 -0800 Subject: [PATCH 3/3] chore: rerun CI after main's test fixes (#2517) Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kyle Zemek