diff --git a/apps/app/src/components/plugin/PluginComposerAccessories.tsx b/apps/app/src/components/plugin/PluginComposerAccessories.tsx
index 19216b209..d66d350ed 100644
--- a/apps/app/src/components/plugin/PluginComposerAccessories.tsx
+++ b/apps/app/src/components/plugin/PluginComposerAccessories.tsx
@@ -29,9 +29,15 @@ function PluginComposerAccessoryList({
const composerHost = usePluginComposerHost();
const scope = composerHost?.scope;
const activeProjectId =
- scope?.kind === "new-thread" ? scope.projectId : (projectId ?? null);
+ scope?.kind === "new-thread" || scope?.kind === "side-chat"
+ ? scope.projectId
+ : (projectId ?? null);
const activeThreadId =
- scope && scope.kind !== "new-thread" ? scope.threadId : (threadId ?? null);
+ scope?.kind === "side-chat"
+ ? (scope.childThreadId ?? scope.parentThreadId)
+ : scope && scope.kind !== "new-thread"
+ ? scope.threadId
+ : (threadId ?? null);
return (
<>
{accessories.map((accessory) => (
diff --git a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
index 2d2fcf7fb..ac4fcdd03 100644
--- a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
+++ b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
@@ -240,10 +240,14 @@ describe("useComposer", () => {
{threadId ?? "null"}
- {composer.scope.kind === "new-thread"
+ {composer.scope.kind === "new-thread" ||
+ composer.scope.kind === "side-chat"
? (composer.scope.projectId ?? "null")
: "none"}
+
diff --git a/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx b/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx
index 19a626b00..8c40976a5 100644
--- a/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx
+++ b/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx
@@ -2,15 +2,18 @@
import type { Thread, ThreadQueuedMessage } from "@bb/domain";
import {
+ act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
-import type { ReactNode } from "react";
+import { StrictMode, type ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FollowUpComposerProps } from "@/components/promptbox/FollowUpPromptBox";
+import type { PluginComposerHost } from "@/components/plugin/plugin-composer-host";
+import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage";
import type { SideChatFixedPanelTab } from "@/lib/fixed-panel-tabs-state";
import { SideChatTabContent } from "./SideChatTabContent";
@@ -21,6 +24,7 @@ const mocks = vi.hoisted(() => ({
defaultExecutionPermissionMode: "auto" as "accept-edits" | "auto" | "full",
noopMutate: vi.fn(),
noopMutateAsync: vi.fn(),
+ latestPluginComposerHost: null as PluginComposerHost | null,
promptMentionArgs: [] as unknown[],
promptMentionSetQuery: vi.fn(),
sendThreadMessageMutateAsync: vi.fn(),
@@ -47,8 +51,11 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({
focusEndKey,
permission,
permissionReadOnly,
+ pluginComposerHost,
readOnly,
stack,
+ suppressPluginComposerAccessories,
+ textEffect,
typeahead,
}: {
attachments: {
@@ -81,6 +88,7 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({
focusEndKey?: string | number;
permissionReadOnly?: boolean;
permission: { value?: string };
+ pluginComposerHost?: PluginComposerHost | null;
readOnly?: boolean;
typeahead: {
command: {
@@ -92,92 +100,123 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({
};
};
stack: ReactNode | null;
- }) => (
-
- composer.onChangeMessage(event.target.value, [])}
- />
-
-
-
-
-
-
-
- {typeahead.command.trigger}
-
- {stack === null ? "null" : "provided"}
-
-
- {execution.model.selected}
-
-
- {execution.reasoning.value}
-
-
- {execution.serviceTier?.value}
-
-
- {permission.value}
-
-
- {attachments.items.length}
-
-
- {executionReadOnly ? "true" : "false"}
-
-
- {readOnly ? "true" : "false"}
-
-
- {permissionReadOnly ? "true" : "false"}
-
- {stack}
-
-
- ),
+ suppressPluginComposerAccessories?: boolean;
+ textEffect?: string | null;
+ }) => {
+ mocks.latestPluginComposerHost = pluginComposerHost ?? null;
+ return (
+
+ composer.onChangeMessage(event.target.value, [])}
+ />
+
+
+
+
+
+
+
+ {typeahead.command.trigger}
+
+ {stack === null ? "null" : "provided"}
+
+
+ {execution.model.selected}
+
+
+ {execution.reasoning.value}
+
+
+ {execution.serviceTier?.value}
+
+
+ {permission.value}
+
+
+ {attachments.items.length}
+
+
+ {executionReadOnly ? "true" : "false"}
+
+
+ {readOnly ? "true" : "false"}
+
+
+ {permissionReadOnly ? "true" : "false"}
+
+
+ {pluginComposerHost
+ ? JSON.stringify(pluginComposerHost.scope)
+ : "null"}
+
+ {textEffect ?? "none"}
+
+ {stack}
+
+
+ );
+ },
}));
vi.mock("@/components/promptbox/ThreadEnvironmentSummary", () => ({
@@ -504,6 +543,7 @@ afterEach(() => {
mocks.threadTimelineRows.length = 0;
mocks.timelineRowsProps.length = 0;
mocks.defaultExecutionPermissionMode = "auto";
+ mocks.latestPluginComposerHost = null;
mocks.threadCreationReasoningLevel = "medium";
mocks.threadCreationSelectedModel = "gpt-5";
mocks.threadCreationServiceTier = undefined;
@@ -513,6 +553,31 @@ afterEach(() => {
});
describe("SideChatTabContent", () => {
+ function ParentDraftProbe() {
+ const draft = usePromptDraftStorage({
+ kind: "thread",
+ projectId: "proj_parent",
+ threadId: "thr_parent",
+ });
+ return (
+ <>
+
{draft.text}
+
+ >
+ );
+ }
+
function makeQueuedMessage(
overrides: Partial
= {},
): ThreadQueuedMessage {
@@ -630,6 +695,195 @@ describe("SideChatTabContent", () => {
);
});
+ it("suppresses inactive plugin accessories and restores them without remounting the editor", () => {
+ const onSetThreadId = vi.fn();
+ const { rerender } = render(
+ buildSideChatElement({
+ isActive: false,
+ onSetThreadId,
+ threadId: null,
+ }),
+ );
+ const composer = screen.getByTestId("side-chat-composer");
+
+ fireEvent.change(composer, { target: { value: "Retained side draft" } });
+ expect(composer.dataset.pluginAccessoriesSuppressed).toBe("true");
+
+ rerender(
+ buildSideChatElement({
+ isActive: true,
+ onSetThreadId,
+ threadId: null,
+ }),
+ );
+
+ expect(screen.getByTestId("side-chat-composer")).toBe(composer);
+ expect(composer.value).toBe("Retained side draft");
+ expect(composer.dataset.pluginAccessoriesSuppressed).toBe("false");
+ });
+
+ it("exposes only the visible side-chat draft to plugins before and after child creation", async () => {
+ mocks.uploadPromptAttachmentMutateAsync.mockResolvedValueOnce({
+ type: "localFile",
+ path: "thread-storage/uploads/note.md",
+ name: "note.md",
+ mimeType: "text/markdown",
+ sizeBytes: 5,
+ });
+ const onSetThreadId = vi.fn();
+ const { rerender } = render(
+ <>
+
+ {buildSideChatElement({ onSetThreadId, threadId: null })}
+ >,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Seed parent draft" }));
+ fireEvent.change(screen.getByTestId("side-chat-composer"), {
+ target: { value: "Visible side-chat draft" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Attach" }));
+ await waitFor(() =>
+ expect(screen.getByTestId("side-chat-attachment-count").textContent).toBe(
+ "1",
+ ),
+ );
+
+ expect(
+ JSON.parse(
+ screen.getByTestId("side-chat-plugin-scope").textContent ?? "",
+ ),
+ ).toEqual({
+ kind: "side-chat",
+ projectId: "proj_parent",
+ parentThreadId: "thr_parent",
+ tabId: "side-chat:one",
+ childThreadId: null,
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Plugin replace" }));
+
+ expect(screen.getByTestId("side-chat-composer")).toHaveProperty(
+ "value",
+ "Plugin replacement",
+ );
+ expect(screen.getByTestId("side-chat-attachment-count").textContent).toBe(
+ "1",
+ );
+ expect(screen.getByTestId("parent-composer-draft").textContent).toBe(
+ "Keep parent draft",
+ );
+ expect(screen.getByTestId("side-chat-composer").dataset.focusEndKey).toBe(
+ "1",
+ );
+
+ rerender(
+ <>
+
+ {buildSideChatElement({ onSetThreadId, threadId: "thr_side" })}
+ >,
+ );
+ expect(
+ JSON.parse(
+ screen.getByTestId("side-chat-plugin-scope").textContent ?? "",
+ ),
+ ).toEqual({
+ kind: "side-chat",
+ projectId: "proj_parent",
+ parentThreadId: "thr_parent",
+ tabId: "side-chat:one",
+ childThreadId: "thr_side",
+ });
+ expect(screen.getByTestId("side-chat-composer")).toHaveProperty(
+ "value",
+ "Plugin replacement",
+ );
+ expect(screen.getByTestId("parent-composer-draft").textContent).toBe(
+ "Keep parent draft",
+ );
+ });
+
+ it("keeps the initial plugin host writable through StrictMode effect replay", () => {
+ render(
+
+ {buildSideChatElement({
+ onSetThreadId: vi.fn(),
+ threadId: null,
+ })}
+ ,
+ );
+
+ fireEvent.change(screen.getByTestId("side-chat-composer"), {
+ target: { value: "Strict side draft" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Plugin replace" }));
+
+ expect(screen.getByTestId("side-chat-composer")).toHaveProperty(
+ "value",
+ "Plugin replacement",
+ );
+ expect(screen.getByTestId("side-chat-composer").dataset.focusEndKey).toBe(
+ "1",
+ );
+ });
+
+ it("ignores stale plugin writes after side-chat ownership changes and unmounts", () => {
+ const onSetThreadId = vi.fn();
+ const view = render(
+ buildSideChatElement({ onSetThreadId, threadId: null }),
+ );
+ fireEvent.change(screen.getByTestId("side-chat-composer"), {
+ target: { value: "Current side draft" },
+ });
+ const staleDraftHost = mocks.latestPluginComposerHost;
+ expect(staleDraftHost).not.toBeNull();
+
+ view.rerender(
+ buildSideChatElement({ onSetThreadId, threadId: "thr_side" }),
+ );
+ act(() => {
+ staleDraftHost?.setDraft({
+ text: "Stale replacement",
+ mentions: [],
+ attachments: [],
+ });
+ });
+ expect(screen.getByTestId("side-chat-composer")).toHaveProperty(
+ "value",
+ "Current side draft",
+ );
+
+ const staleActiveHost = mocks.latestPluginComposerHost;
+ view.rerender(
+ buildSideChatElement({
+ isActive: false,
+ onSetThreadId,
+ threadId: "thr_side",
+ }),
+ );
+ expect(screen.getByTestId("side-chat-plugin-scope").textContent).toBe(
+ "null",
+ );
+ act(() => {
+ staleActiveHost?.setDraft({
+ text: "Hidden replacement",
+ mentions: [],
+ attachments: [],
+ });
+ });
+ expect(screen.getByTestId("side-chat-composer")).toHaveProperty(
+ "value",
+ "Current side draft",
+ );
+
+ view.unmount();
+ act(() => {
+ staleActiveHost?.setDraft({
+ text: "Unmounted replacement",
+ mentions: [],
+ attachments: [],
+ });
+ });
+ });
+
it("keeps permission locked while model controls remain editable", () => {
renderDraftSideChat();
@@ -646,7 +900,20 @@ describe("SideChatTabContent", () => {
});
it("updates an inline queue row with its captured version and execution values", async () => {
- mocks.queuedMessages = [makeQueuedMessage()];
+ mocks.queuedMessages = [
+ makeQueuedMessage({
+ content: [
+ { type: "text", text: "Queued side message", mentions: [] },
+ {
+ type: "localFile",
+ path: "thread-storage/uploads/queued-note.md",
+ name: "queued-note.md",
+ mimeType: "text/markdown",
+ sizeBytes: 7,
+ },
+ ],
+ }),
+ ];
renderSideChat({ threadId: "thr_side" });
fireEvent.change(screen.getByTestId("side-chat-composer"), {
target: { value: "Untouched bottom side draft" },
@@ -675,17 +942,42 @@ describe("SideChatTabContent", () => {
expect(
screen.getByTestId("side-chat-permission-read-only").textContent,
).toBe("true");
+ expect(screen.getByTestId("side-chat-attachment-count").textContent).toBe(
+ "1",
+ );
+ expect(
+ JSON.parse(
+ screen.getByTestId("side-chat-plugin-scope").textContent ?? "",
+ ),
+ ).toEqual({
+ kind: "queued-message",
+ threadId: "thr_side",
+ queuedMessageId: "qmsg_side_1",
+ });
fireEvent.change(screen.getByTestId("side-chat-composer"), {
target: { value: "Edited side queue" },
});
+ fireEvent.click(screen.getByRole("button", { name: "Plugin replace" }));
+ expect(screen.getByTestId("side-chat-attachment-count").textContent).toBe(
+ "1",
+ );
fireEvent.click(screen.getByRole("button", { name: "Send" }));
await waitFor(() =>
expect(mocks.updateQueuedMessageMutateAsync).toHaveBeenCalledWith({
expectedUpdatedAt: 17,
id: "thr_side",
- input: [{ type: "text", text: "Edited side queue", mentions: [] }],
+ input: [
+ { type: "text", text: "Plugin replacement", mentions: [] },
+ {
+ type: "localFile",
+ path: "thread-storage/uploads/queued-note.md",
+ name: "queued-note.md",
+ mimeType: "text/markdown",
+ sizeBytes: 7,
+ },
+ ],
queuedMessageId: "qmsg_side_1",
}),
);
diff --git a/apps/app/src/components/secondary-panel/SideChatTabContent.tsx b/apps/app/src/components/secondary-panel/SideChatTabContent.tsx
index b8aa7d744..a8dd751a3 100644
--- a/apps/app/src/components/secondary-panel/SideChatTabContent.tsx
+++ b/apps/app/src/components/secondary-panel/SideChatTabContent.tsx
@@ -34,6 +34,7 @@ import {
FollowUpPromptBox,
type FollowUpComposerProps,
} from "@/components/promptbox/FollowUpPromptBox";
+import type { PluginComposerHost } from "@/components/plugin/plugin-composer-host";
import { withAutomationPromptAction } from "@/components/promptbox/PromptBoxActionsMenu";
import {
QueuedMessagesList,
@@ -96,6 +97,7 @@ import {
} from "@/lib/side-chat-create-request";
import type { SideChatFixedPanelTab } from "@/lib/fixed-panel-tabs-state";
import { getMutationErrorMessage } from "@/lib/mutation-errors";
+import { useComposerTextEffect } from "@/lib/composer-text-effects";
import { BbHttpError } from "@/lib/sdk";
import type { QueuedMessageReorderRequest } from "@/lib/queued-message-reorder";
import { appToast } from "@/components/ui/app-toast";
@@ -528,12 +530,140 @@ export function SideChatTabContent({
}),
[promptDraft.attachments, promptDraft.mentions, promptDraft.text],
);
+ const currentPromptDraftRef = useRef(currentPromptDraft);
+ currentPromptDraftRef.current = currentPromptDraft;
+ const inlineEditingQueuedMessageRef =
+ useRef(inlineEditingQueuedMessage);
+ inlineEditingQueuedMessageRef.current = inlineEditingQueuedMessage;
const currentPromptDraftInput = useMemo(
() => promptDraftToInput(currentPromptDraft),
[currentPromptDraft],
);
const activeComposerDraft =
inlineEditingQueuedMessage?.draft ?? currentPromptDraft;
+ const queuedEditSessionId = inlineEditingQueuedMessage?.editSessionId ?? null;
+ const queuedEditOwnerThreadId =
+ inlineEditingQueuedMessage?.ownerThreadId ?? null;
+ const queuedEditMessageId =
+ inlineEditingQueuedMessage?.queuedMessageId ?? null;
+ const queuedComposerIdentity = useMemo(
+ () =>
+ queuedEditSessionId === null ||
+ queuedEditOwnerThreadId === null ||
+ queuedEditMessageId === null
+ ? null
+ : {
+ editSessionId: queuedEditSessionId,
+ ownerThreadId: queuedEditOwnerThreadId,
+ queuedMessageId: queuedEditMessageId,
+ },
+ [queuedEditMessageId, queuedEditOwnerThreadId, queuedEditSessionId],
+ );
+ const activeComposerIdentity = queuedComposerIdentity
+ ? `queued-message:${queuedComposerIdentity.ownerThreadId}:${queuedComposerIdentity.queuedMessageId}:${queuedComposerIdentity.editSessionId}`
+ : `side-chat:${sourceThread.projectId}:${sourceThread.id}:${tab.id}:${childThreadId ?? ""}`;
+ const composerOwnershipRef = useRef({
+ activeComposerIdentity,
+ isActive,
+ version: 0,
+ });
+ if (
+ composerOwnershipRef.current.activeComposerIdentity !==
+ activeComposerIdentity ||
+ composerOwnershipRef.current.isActive !== isActive
+ ) {
+ composerOwnershipRef.current = {
+ activeComposerIdentity,
+ isActive,
+ version: composerOwnershipRef.current.version + 1,
+ };
+ }
+ const activeComposerHostIdentity = `${activeComposerIdentity}:ownership:${composerOwnershipRef.current.version}`;
+ const activeComposerIdentityRef = useRef(
+ isActive ? activeComposerHostIdentity : null,
+ );
+ activeComposerIdentityRef.current = isActive
+ ? activeComposerHostIdentity
+ : null;
+ const activeComposerDraftRef = useRef(activeComposerDraft);
+ activeComposerDraftRef.current = activeComposerDraft;
+ const pluginComposerHostBinding = useMemo<
+ Omit
+ >(() => {
+ const identity = activeComposerHostIdentity;
+ const initialDraft = activeComposerDraftRef.current;
+ const queuedEdit = queuedComposerIdentity;
+ const isCurrentQueuedEdit = (
+ current: InlineQueuedMessageEditState | null,
+ ): current is InlineQueuedMessageEditState =>
+ queuedEdit !== null &&
+ current?.editSessionId === queuedEdit.editSessionId &&
+ current.ownerThreadId === queuedEdit.ownerThreadId &&
+ current.queuedMessageId === queuedEdit.queuedMessageId;
+
+ return {
+ scope:
+ queuedEdit === null
+ ? {
+ kind: "side-chat",
+ projectId: sourceThread.projectId,
+ parentThreadId: sourceThread.id,
+ tabId: tab.id,
+ childThreadId,
+ }
+ : {
+ kind: "queued-message",
+ threadId: queuedEdit.ownerThreadId,
+ queuedMessageId: queuedEdit.queuedMessageId,
+ },
+ textEffectKey: identity,
+ getCurrent: () => {
+ if (activeComposerIdentityRef.current !== identity) {
+ return initialDraft;
+ }
+ const currentQueuedEdit = inlineEditingQueuedMessageRef.current;
+ return isCurrentQueuedEdit(currentQueuedEdit)
+ ? currentQueuedEdit.draft
+ : currentPromptDraftRef.current;
+ },
+ setDraft: (draft) => {
+ if (activeComposerIdentityRef.current !== identity) {
+ return;
+ }
+ if (queuedEdit !== null) {
+ setInlineEditingQueuedMessage((current) =>
+ isCurrentQueuedEdit(current) ? { ...current, draft } : current,
+ );
+ return;
+ }
+ setStoredPromptDraft(draft);
+ },
+ focus: () => {
+ if (activeComposerIdentityRef.current === identity) {
+ setComposerFocusNonce((nonce) => nonce + 1);
+ }
+ },
+ };
+ }, [
+ activeComposerHostIdentity,
+ childThreadId,
+ queuedComposerIdentity,
+ setStoredPromptDraft,
+ sourceThread.id,
+ sourceThread.projectId,
+ tab.id,
+ ]);
+ const pluginComposerHost = useMemo(
+ () => ({
+ ...pluginComposerHostBinding,
+ draft: activeComposerDraft,
+ }),
+ [activeComposerDraft, pluginComposerHostBinding],
+ );
+ const activePluginComposerHost = isActive ? pluginComposerHost : null;
+ const composerTextEffect = useComposerTextEffect(
+ activePluginComposerHost?.textEffectKey ?? null,
+ );
const activeComposerDraftInput = useMemo(
() => promptDraftToInput(activeComposerDraft),
[activeComposerDraft],
@@ -721,10 +851,14 @@ export function SideChatTabContent({
useEffect(() => {
isMountedRef.current = true;
+ activeComposerIdentityRef.current = isActive
+ ? activeComposerHostIdentity
+ : null;
return () => {
isMountedRef.current = false;
+ activeComposerIdentityRef.current = null;
};
- }, []);
+ }, [activeComposerHostIdentity, isActive]);
useEffect(() => {
if (childHasUserMessage) {
setOptimisticFirstUserRow(null);
@@ -1525,7 +1659,8 @@ export function SideChatTabContent({
// snapshots, so the displayed label can't drift from the permission the
// side chat actually runs with. Undefined until defaults load, which
// keeps the picker hidden rather than guessing.
- value: inlineEditingQueuedMessage?.permissionMode ?? sideChatPermissionMode,
+ value:
+ inlineEditingQueuedMessage?.permissionMode ?? sideChatPermissionMode,
options: permissionModeOptions,
onChange: noop,
supported: supportsPermissionModeSelection,
@@ -1589,6 +1724,8 @@ export function SideChatTabContent({
attachments={attachmentsConfig}
stack={queuedMessagesStack ?? <>>}
composer={composerConfig}
+ pluginComposerHost={activePluginComposerHost}
+ textEffect={composerTextEffect}
composerTarget={inlineComposerTarget}
environmentSummary={environmentSummary}
contextWindowUsage={null}
@@ -1598,6 +1735,7 @@ export function SideChatTabContent({
permissionReadOnly
typeahead={typeaheadConfig}
promptActions={promptActions}
+ suppressPluginComposerAccessories={!isActive}
zenModeResetKey={childThreadId ?? tab.id}
focusEndKey={composerFocusNonce}
// A side chat is a secondary composer: it stays mounted (often hidden)
diff --git a/apps/app/src/lib/plugin-sdk-hooks.ts b/apps/app/src/lib/plugin-sdk-hooks.ts
index ef16339d7..e1f00a29b 100644
--- a/apps/app/src/lib/plugin-sdk-hooks.ts
+++ b/apps/app/src/lib/plugin-sdk-hooks.ts
@@ -437,27 +437,34 @@ export function useComposer(): PluginComposerApi {
const composerScope = composerHost?.scope;
const threadRowStatusThreadId =
- composerScope?.kind === "thread" || composerScope?.kind === "queued-message"
- ? composerScope.threadId
- : composerScope === undefined && threadId !== undefined
- ? threadId
- : null;
+ composerScope?.kind === "side-chat"
+ ? (composerScope.childThreadId ?? composerScope.parentThreadId)
+ : composerScope?.kind === "thread" ||
+ composerScope?.kind === "queued-message"
+ ? composerScope.threadId
+ : composerScope === undefined && threadId !== undefined
+ ? threadId
+ : null;
const threadRowStatusScopeKey =
composerScope?.kind === "queued-message"
? `queued-message:${composerScope.threadId}:${composerScope.queuedMessageId}`
- : threadRowStatusThreadId === null
- ? "new-thread"
- : `thread:${threadRowStatusThreadId}`;
+ : composerScope?.kind === "side-chat"
+ ? `side-chat:${composerScope.projectId}:${composerScope.parentThreadId}:${composerScope.tabId}:${composerScope.childThreadId ?? ""}`
+ : threadRowStatusThreadId === null
+ ? "new-thread"
+ : `thread:${threadRowStatusThreadId}`;
const composerOwnershipScopeKey =
composerScope?.kind === "queued-message"
? `queued-message:${composerScope.threadId}:${composerScope.queuedMessageId}`
- : composerScope?.kind === "thread"
- ? `thread:${composerScope.threadId}`
- : composerScope?.kind === "new-thread"
- ? `new-thread:${composerScope.projectId ?? "null"}`
- : threadId !== undefined
- ? `thread:${threadId}`
- : `new-thread:${projectId ?? "null"}`;
+ : composerScope?.kind === "side-chat"
+ ? `side-chat:${composerScope.projectId}:${composerScope.parentThreadId}:${composerScope.tabId}:${composerScope.childThreadId ?? ""}`
+ : composerScope?.kind === "thread"
+ ? `thread:${composerScope.threadId}`
+ : composerScope?.kind === "new-thread"
+ ? `new-thread:${composerScope.projectId ?? "null"}`
+ : threadId !== undefined
+ ? `thread:${threadId}`
+ : `new-thread:${projectId ?? "null"}`;
const scopeOwnershipKey = [
pluginId,
composerOwnershipScopeKey,
diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts
index b95a5f034..20b2784f9 100644
--- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts
+++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts
@@ -397,6 +397,12 @@ type PluginComposerScope = {
kind: "queued-message";
threadId: string;
queuedMessageId: string;
+} | {
+ kind: "side-chat";
+ projectId: string;
+ parentThreadId: string;
+ tabId: string;
+ childThreadId: string | null;
} | {
kind: "new-thread";
/** Root compose's effective selected project; null only while unresolved. */
diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-testing-app.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-testing-app.d.ts
index 95434b429..9c140f313 100644
--- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-testing-app.d.ts
+++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-testing-app.d.ts
@@ -7,7 +7,7 @@
import { ReactNode, ComponentType } from 'react';
import { RenderResult } from '@testing-library/react';
-import { PluginHomepageSectionRegistration, PluginSettingsSectionRegistration, PluginNavPanelRegistration, PluginThreadPanelActionRegistration, PluginComposerAccessoryRegistration, PluginPendingInteractionRegistration, PluginSidebarFooterActionRegistration, PluginFileOpenerRegistration, PluginMessageDirectiveRegistration, PluginComposerTextEffect, PluginComposerThreadRowStatus, PluginComposerMention, PluginAppDefinition, PluginRpcContract, StandardSchemaV1InferInput, PluginRpcResult, PluginRealtimeConnectionState } from '@bb/plugin-sdk';
+import { PluginHomepageSectionRegistration, PluginSettingsSectionRegistration, PluginNavPanelRegistration, PluginThreadPanelActionRegistration, PluginComposerAccessoryRegistration, PluginPendingInteractionRegistration, PluginSidebarFooterActionRegistration, PluginFileOpenerRegistration, PluginMessageDirectiveRegistration, PluginComposerTextEffect, PluginComposerThreadRowStatus, PluginComposerMention, PluginAppDefinition, PluginRpcContract, StandardSchemaV1InferInput, PluginRpcResult, PluginRealtimeConnectionState, PluginComposerScope } from '@bb/plugin-sdk';
/**
* `@bb/plugin-sdk/testing/app` — the frontend plugin test harness. Tests a
@@ -118,9 +118,10 @@ interface RenderSlotOptions
{composer.scope.kind}
+
+ {JSON.stringify(composer.scope)}
+
{composer.text}