From 5413ac62d85bf3af3c8e65c738ac3d765a8ca56e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 19 Jul 2026 03:13:50 -0700 Subject: [PATCH 1/2] Support plugins in side-chat composers --- .../plugin/PluginComposerAccessories.tsx | 10 +- .../plugin/plugin-slot-mounts.test.tsx | 113 ++++- .../SideChatTabContent.test.tsx | 412 ++++++++++++++---- .../secondary-panel/SideChatTabContent.tsx | 136 +++++- apps/app/src/lib/plugin-sdk-hooks.ts | 37 +- .../bundled-types/bb-plugin-sdk-app.d.ts | 6 + .../bb-plugin-sdk-testing-app.d.ts | 5 +- .../bundled-types/bb-plugin-sdk.d.ts | 6 + packages/plugin-sdk/src/app-contract.ts | 7 + .../testing/__tests__/app-harness.test.tsx | 29 ++ packages/plugin-sdk/src/testing/app.tsx | 19 +- .../src/generated/plugin-sdk-dts.generated.ts | 4 +- .../plugin-starter-files.generated.ts | 2 +- 13 files changed, 667 insertions(+), 119 deletions(-) diff --git a/apps/app/src/components/plugin/PluginComposerAccessories.tsx b/apps/app/src/components/plugin/PluginComposerAccessories.tsx index 19216b209b..d66d350ed6 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 2d2fcf7fb4..ac4fcdd036 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"}
+
+ {JSON.stringify(composer.scope)} +
{composer.text}
{String(methodsAreStable)} @@ -553,6 +557,113 @@ describe("useComposer", () => { expect(getPluginThreadRowStatus("thr_queue")).toBeNull(); }); + it("binds side-chat accessories and hooks to the visible side-chat draft", () => { + registerComposerProbe("side"); + + function SideChatComposerHarness() { + const [childThreadId, setChildThreadId] = useState(null); + const [draft, setDraft] = useState({ + text: "side-chat draft", + mentions: [], + attachments: [ + { + type: "localFile", + path: "uploads/side-spec.md", + name: "side-spec.md", + sizeBytes: 42, + }, + ], + }); + const draftRef = useRef(draft); + draftRef.current = draft; + const host = useMemo( + () => ({ + scope: { + kind: "side-chat", + projectId: "proj_side", + parentThreadId: "thr_parent", + tabId: "side-chat:one", + childThreadId, + }, + draft, + textEffectKey: `side-chat:side-chat:one:${childThreadId ?? ""}`, + getCurrent: () => draftRef.current, + setDraft, + focus: () => {}, + }), + [childThreadId, draft], + ); + + return ( + + +
+ {JSON.stringify(draft.attachments)} +
+ +
+ ); + } + + render( + + + , + ); + + expect(screen.getByText("scope: side-chat")).toBeDefined(); + expect(screen.getByTestId("side-accessory-project").textContent).toBe( + "proj_side", + ); + expect(screen.getByTestId("side-accessory-thread").textContent).toBe( + "thr_parent", + ); + expect( + JSON.parse(screen.getByTestId("side-scope-details").textContent ?? "{}"), + ).toEqual({ + kind: "side-chat", + projectId: "proj_side", + parentThreadId: "thr_parent", + tabId: "side-chat:one", + childThreadId: null, + }); + + fireEvent.click(screen.getByText("side-replace")); + expect(screen.getByTestId("side-composer-text").textContent).toBe( + "replacement", + ); + expect( + JSON.parse(screen.getByTestId("side-attachments").textContent ?? "[]"), + ).toHaveLength(1); + + fireEvent.click(screen.getByText("side-start-row-status")); + expect(getPluginThreadRowStatus("thr_parent")).toEqual({ + icon: "AiContentGenerator01", + label: "Prompt Shaper improving prompt", + effect: "shimmer", + }); + + fireEvent.click(screen.getByText("create-side-child")); + expect(screen.getByTestId("side-accessory-thread").textContent).toBe( + "thr_side", + ); + expect(getPluginThreadRowStatus("thr_parent")).toBeNull(); + expect( + JSON.parse(screen.getByTestId("side-scope-details").textContent ?? "{}"), + ).toEqual({ + kind: "side-chat", + projectId: "proj_side", + parentThreadId: "thr_parent", + tabId: "side-chat:one", + childThreadId: "thr_side", + }); + expect(screen.getByTestId("side-composer-text").textContent).toBe( + "replacement", + ); + }); + it("targets the new-thread composer without leaking replacements to thread drafts", () => { registerComposerProbe("edit-new"); render( diff --git a/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx b/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx index 19a626b00f..b7e84afa5f 100644 --- a/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx +++ b/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx @@ -2,6 +2,7 @@ import type { Thread, ThreadQueuedMessage } from "@bb/domain"; import { + act, cleanup, fireEvent, render, @@ -11,6 +12,8 @@ import { import 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,10 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({ focusEndKey, permission, permissionReadOnly, + pluginComposerHost, readOnly, stack, + textEffect, typeahead, }: { attachments: { @@ -81,6 +87,7 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({ focusEndKey?: string | number; permissionReadOnly?: boolean; permission: { value?: string }; + pluginComposerHost?: PluginComposerHost | null; readOnly?: boolean; typeahead: { command: { @@ -92,92 +99,119 @@ 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} - -
- ), + 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 +538,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 +548,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 +690,144 @@ describe("SideChatTabContent", () => { ); }); + 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("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 +844,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 +886,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 b8aa7d7443..b0ea2c0da7 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], @@ -723,6 +853,7 @@ export function SideChatTabContent({ isMountedRef.current = true; return () => { isMountedRef.current = false; + activeComposerIdentityRef.current = null; }; }, []); useEffect(() => { @@ -1525,7 +1656,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 +1721,8 @@ export function SideChatTabContent({ attachments={attachmentsConfig} stack={queuedMessagesStack ?? <>} composer={composerConfig} + pluginComposerHost={activePluginComposerHost} + textEffect={composerTextEffect} composerTarget={inlineComposerTarget} environmentSummary={environmentSummary} contextWindowUsage={null} diff --git a/apps/app/src/lib/plugin-sdk-hooks.ts b/apps/app/src/lib/plugin-sdk-hooks.ts index ef16339d70..e1f00a29b5 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 b95a5f034c..20b2784f9b 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 95434b429c..9c140f3139 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}
) : null}
diff --git a/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx b/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx index b7e84afa5f..8c40976a51 100644 --- a/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx +++ b/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx @@ -9,7 +9,7 @@ import { 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"; @@ -54,6 +54,7 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({ pluginComposerHost, readOnly, stack, + suppressPluginComposerAccessories, textEffect, typeahead, }: { @@ -99,6 +100,7 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({ }; }; stack: ReactNode | null; + suppressPluginComposerAccessories?: boolean; textEffect?: string | null; }) => { mocks.latestPluginComposerHost = pluginComposerHost ?? null; @@ -107,6 +109,9 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({ composer.onChangeMessage(event.target.value, [])} /> @@ -690,6 +695,33 @@ 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", @@ -769,6 +801,30 @@ describe("SideChatTabContent", () => { ); }); + 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( diff --git a/apps/app/src/components/secondary-panel/SideChatTabContent.tsx b/apps/app/src/components/secondary-panel/SideChatTabContent.tsx index b0ea2c0da7..a8dd751a33 100644 --- a/apps/app/src/components/secondary-panel/SideChatTabContent.tsx +++ b/apps/app/src/components/secondary-panel/SideChatTabContent.tsx @@ -851,11 +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); @@ -1732,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)