diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md
index b76db3ca4e..b09e3197c8 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,22 @@ 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` 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.
`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..0b96da6658
--- /dev/null
+++ b/src/features/conversation/EmbeddedConversation.test.tsx
@@ -0,0 +1,410 @@
+// @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;
+ }) => (
+
+