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"}
+
+ {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/promptbox/FollowUpPromptBox.test.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx index e42cf7b24..7637c9b95 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx @@ -50,6 +50,7 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({ onSubmit, promptBoxRef, submission, + suppressPluginComposerAccessories, zenMode, heightAnimationKey, }: { @@ -66,6 +67,7 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({ } | null; }; submission?: { onModifierSubmit?: () => void }; + suppressPluginComposerAccessories?: boolean; zenMode?: { resetKey: string | number }; heightAnimationKey?: string | number; }) => ( @@ -74,6 +76,9 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({ data-compact={compact?.isCompact} data-zen-reset-key={zenMode?.resetKey} data-height-animation-key={heightAnimationKey} + data-plugin-accessories-suppressed={ + suppressPluginComposerAccessories ? "true" : "false" + } > {footerStart} { target.remove(); }); + it("forwards accessory suppression changes without remounting the composer", () => { + const props = createFollowUpPromptBoxProps({ kind: "ready" }); + const { rerender } = render( + , + ); + const promptBox = screen.getByTestId("prompt-box"); + const input = screen.getByLabelText("Follow-up prompt"); + + expect(promptBox.dataset.pluginAccessoriesSuppressed).toBe("true"); + + rerender( + , + ); + + expect(screen.getByTestId("prompt-box")).toBe(promptBox); + expect(screen.getByLabelText("Follow-up prompt")).toBe(input); + expect(promptBox.dataset.pluginAccessoriesSuppressed).toBe("false"); + }); + it("scrolls to the bottom after submitting a ready follow-up", () => { const props = createFollowUpPromptBoxProps({ kind: "ready" }); render(); diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index ecc236b20..1051c183b 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -199,6 +199,8 @@ export interface FollowUpPromptBoxProps { permissionReadOnly?: boolean; typeahead: TypeaheadConfig; promptActions?: readonly PromptBoxAction[]; + /** Suppress plugin accessories while a retained secondary composer is inactive. */ + suppressPluginComposerAccessories?: boolean; /** Optional transient draft host exposed to plugin composer hooks. */ pluginComposerHost?: PluginComposerHost | null; textEffect?: PluginComposerTextEffect | null; @@ -256,6 +258,7 @@ function FollowUpPromptBoxWithComposer({ permissionReadOnly, typeahead, promptActions, + suppressPluginComposerAccessories, pluginComposerHost, textEffect, zenModeResetKey, @@ -582,6 +585,7 @@ function FollowUpPromptBoxWithComposer({ typeahead={typeahead} attachments={attachments} promptActions={promptActions} + suppressPluginComposerAccessories={suppressPluginComposerAccessories} compact={compactConfig} zenMode={{ layout: "thread", diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 473e551e6..14eeaab2b 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -39,6 +39,12 @@ import type { ProviderCommandSuggestion, } from "./mentions/types"; +vi.mock("@/components/plugin/PluginComposerAccessories", () => ({ + PluginComposerAccessories: () => ( + + ), +})); + type PromptBoxProps = ComponentProps; interface PromptChange { @@ -297,7 +303,8 @@ async function selectPromptAction(label: string) { expect(document.activeElement).toBe(getPromptEditorElement()), ); await act( - () => new Promise((resolve) => requestAnimationFrame(() => resolve())), + () => + new Promise((resolve) => requestAnimationFrame(() => resolve())), ); const trigger = screen.getByRole("button", { name: "Prompt actions" }); fireEvent.pointerDown(trigger, { button: 0 }); @@ -446,11 +453,34 @@ describe("suppressPromptEditorAnchorActivation", () => { }); describe("PromptBoxInternal controlled value sync", () => { + it("suppresses and restores plugin accessories without remounting the editor", () => { + const props = createPromptBoxProps({ value: "Retained draft" }); + const view = render(); + const editor = getPromptEditorElement(); + + expect(screen.getByTestId("plugin-composer-accessories")).not.toBeNull(); + + view.rerender( + , + ); + + expect(screen.queryByTestId("plugin-composer-accessories")).toBeNull(); + expect(getPromptEditorElement()).toBe(editor); + + view.rerender( + , + ); + + expect(screen.getByTestId("plugin-composer-accessories")).not.toBeNull(); + expect(getPromptEditorElement()).toBe(editor); + }); + it("decorates only draft text while shimmering and removes it when cleared", async () => { const props = createPromptBoxProps({ value: "Keep this draft readable" }); - const view = render( - , - ); + const view = render(); await waitFor(() => { expect( @@ -465,9 +495,7 @@ describe("PromptBoxInternal controlled value sync", () => { view.rerender(); await waitFor(() => { - expect( - view.container.querySelector(".prompt-text-shimmer"), - ).toBeNull(); + expect(view.container.querySelector(".prompt-text-shimmer")).toBeNull(); }); }); diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index e00da91ea..28abe1389 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -344,6 +344,8 @@ export interface PromptBoxInternalProps { mentionMenuPlacement: MentionMenuPlacement; attachments?: AttachmentsConfig; promptActions?: readonly PromptBoxAction[]; + /** Suppress plugin-rendered composer accessories without unmounting the editor. */ + suppressPluginComposerAccessories?: boolean; zenMode?: PromptBoxZenModeConfig; /** Optional one-line presentation for unfocused mobile follow-up composers. */ compact?: PromptBoxCompactConfig; @@ -1035,6 +1037,7 @@ export function PromptBoxInternal({ mentionMenuPlacement, attachments: attachmentConfig = {}, promptActions, + suppressPluginComposerAccessories = false, zenMode = {}, compact, containerCompactPlaceholder, @@ -2842,7 +2845,9 @@ export function PromptBoxInternal({ onAction={applyPromptAction} /> {footerStart} - + {!suppressPluginComposerAccessories ? ( + + ) : null}
) : null}
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}