Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/app/src/components/plugin/PluginComposerAccessories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
Expand Down
113 changes: 112 additions & 1 deletion apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,10 +240,14 @@ describe("useComposer", () => {
{threadId ?? "null"}
</div>
<div data-testid={`${label}-scope-project`}>
{composer.scope.kind === "new-thread"
{composer.scope.kind === "new-thread" ||
composer.scope.kind === "side-chat"
? (composer.scope.projectId ?? "null")
: "none"}
</div>
<div data-testid={`${label}-scope-details`}>
{JSON.stringify(composer.scope)}
</div>
<div data-testid={`${label}-composer-text`}>{composer.text}</div>
<div data-testid={`${label}-stable-methods`}>
{String(methodsAreStable)}
Expand Down Expand Up @@ -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<string | null>(null);
const [draft, setDraft] = useState<PromptDraftState>({
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<PluginComposerHost>(
() => ({
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 (
<PluginComposerHostProvider value={host}>
<PluginComposerAccessories />
<div data-testid="side-attachments">
{JSON.stringify(draft.attachments)}
</div>
<button type="button" onClick={() => setChildThreadId("thr_side")}>
create-side-child
</button>
</PluginComposerHostProvider>
);
}

render(
<MemoryRouter initialEntries={["/threads/thr_parent"]}>
<SideChatComposerHarness />
</MemoryRouter>,
);

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(
Expand Down
27 changes: 27 additions & 0 deletions apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({
onSubmit,
promptBoxRef,
submission,
suppressPluginComposerAccessories,
zenMode,
heightAnimationKey,
}: {
Expand All @@ -66,6 +67,7 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({
} | null;
};
submission?: { onModifierSubmit?: () => void };
suppressPluginComposerAccessories?: boolean;
zenMode?: { resetKey: string | number };
heightAnimationKey?: string | number;
}) => (
Expand All @@ -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}
<input
Expand Down Expand Up @@ -274,6 +279,28 @@ describe("FollowUpPromptBox", () => {
target.remove();
});

it("forwards accessory suppression changes without remounting the composer", () => {
const props = createFollowUpPromptBoxProps({ kind: "ready" });
const { rerender } = render(
<FollowUpPromptBox {...props} suppressPluginComposerAccessories />,
);
const promptBox = screen.getByTestId("prompt-box");
const input = screen.getByLabelText("Follow-up prompt");

expect(promptBox.dataset.pluginAccessoriesSuppressed).toBe("true");

rerender(
<FollowUpPromptBox
{...props}
suppressPluginComposerAccessories={false}
/>,
);

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(<FollowUpPromptBox {...props} />);
Expand Down
4 changes: 4 additions & 0 deletions apps/app/src/components/promptbox/FollowUpPromptBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -256,6 +258,7 @@ function FollowUpPromptBoxWithComposer({
permissionReadOnly,
typeahead,
promptActions,
suppressPluginComposerAccessories,
pluginComposerHost,
textEffect,
zenModeResetKey,
Expand Down Expand Up @@ -582,6 +585,7 @@ function FollowUpPromptBoxWithComposer({
typeahead={typeahead}
attachments={attachments}
promptActions={promptActions}
suppressPluginComposerAccessories={suppressPluginComposerAccessories}
compact={compactConfig}
zenMode={{
layout: "thread",
Expand Down
42 changes: 35 additions & 7 deletions apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ import type {
ProviderCommandSuggestion,
} from "./mentions/types";

vi.mock("@/components/plugin/PluginComposerAccessories", () => ({
PluginComposerAccessories: () => (
<span data-testid="plugin-composer-accessories" />
),
}));

type PromptBoxProps = ComponentProps<typeof PromptBoxInternal>;

interface PromptChange {
Expand Down Expand Up @@ -297,7 +303,8 @@ async function selectPromptAction(label: string) {
expect(document.activeElement).toBe(getPromptEditorElement()),
);
await act(
() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
);
const trigger = screen.getByRole("button", { name: "Prompt actions" });
fireEvent.pointerDown(trigger, { button: 0 });
Expand Down Expand Up @@ -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(<PromptBoxInternal {...props} />);
const editor = getPromptEditorElement();

expect(screen.getByTestId("plugin-composer-accessories")).not.toBeNull();

view.rerender(
<PromptBoxInternal {...props} suppressPluginComposerAccessories />,
);

expect(screen.queryByTestId("plugin-composer-accessories")).toBeNull();
expect(getPromptEditorElement()).toBe(editor);

view.rerender(
<PromptBoxInternal
{...props}
suppressPluginComposerAccessories={false}
/>,
);

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(
<PromptBoxInternal {...props} textEffect="shimmer" />,
);
const view = render(<PromptBoxInternal {...props} textEffect="shimmer" />);

await waitFor(() => {
expect(
Expand All @@ -465,9 +495,7 @@ describe("PromptBoxInternal controlled value sync", () => {

view.rerender(<PromptBoxInternal {...props} textEffect={null} />);
await waitFor(() => {
expect(
view.container.querySelector(".prompt-text-shimmer"),
).toBeNull();
expect(view.container.querySelector(".prompt-text-shimmer")).toBeNull();
});
});

Expand Down
7 changes: 6 additions & 1 deletion apps/app/src/components/promptbox/PromptBoxInternal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1035,6 +1037,7 @@ export function PromptBoxInternal({
mentionMenuPlacement,
attachments: attachmentConfig = {},
promptActions,
suppressPluginComposerAccessories = false,
zenMode = {},
compact,
containerCompactPlaceholder,
Expand Down Expand Up @@ -2842,7 +2845,9 @@ export function PromptBoxInternal({
onAction={applyPromptAction}
/>
{footerStart}
<PluginComposerAccessories />
{!suppressPluginComposerAccessories ? (
<PluginComposerAccessories />
) : null}
</div>
) : null}
<div className="flex shrink-0 flex-row items-center gap-1">
Expand Down
Loading
Loading