Skip to content
Open
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
9 changes: 8 additions & 1 deletion apps/app/src/components/plugin/PluginPanelActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type PluginThreadPanelActionSlot,
} from "@/lib/plugin-slots";
import type { PluginPanelFixedPanelTab } from "@/lib/fixed-panel-tabs-state";
import { revealPluginThreadMessage } from "@/lib/plugin-thread-message-reveal";
import {
parsePersistedPluginPanelParams,
serializePluginPanelParams,
Expand Down Expand Up @@ -178,7 +179,13 @@ function ActionTabContent({
slotKind="threadPanelAction"
slotId={action.id}
>
<action.component threadId={threadId} params={params} />
<action.component
threadId={threadId}
params={params}
experimental_revealMessage={(messageId) =>
revealPluginThreadMessage(threadId, messageId)
}
/>
</PluginSlotMount>
</div>
);
Expand Down
43 changes: 43 additions & 0 deletions apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PERSONAL_PROJECT_ID } from "@bb/domain";
import type { PluginComposerApi, PluginThreadPanelProps } from "@bb/plugin-sdk";
import { createPluginPanelFixedPanelTab } from "@/lib/fixed-panel-tabs-state";
import { registerPluginThreadMessageRevealHandler } from "@/lib/plugin-thread-message-reveal";
import {
resetPluginSlotStoreForTest,
setPluginSlotRegistrations,
Expand Down Expand Up @@ -1718,6 +1719,18 @@ describe("plugin thread panel actions", () => {
);
}

function RevealProbe({ experimental_revealMessage }: PluginThreadPanelProps) {
const [result, setResult] = useState("idle");
return (
<button
type="button"
onClick={() => void experimental_revealMessage("msg_1").then(setResult)}
>
reveal:{result}
</button>
);
}

function ActionsProbe({
threadId,
openPluginPanel,
Expand Down Expand Up @@ -1784,6 +1797,36 @@ describe("plugin thread panel actions", () => {
).toBeDefined();
});

it("scopes panel message reveal to the panel thread", async () => {
const reveal = vi.fn().mockResolvedValue("revealed");
const unregister = registerPluginThreadMessageRevealHandler(
"thr_9",
reveal,
);
setPluginSlotRegistrations(
"demo",
registrationSet({
threadPanelActions: [
{ id: "comments", title: "Comments", component: RevealProbe },
],
}),
);
const tab = createPluginPanelFixedPanelTab({
actionId: "comments",
paramsJson: null,
pluginId: "demo",
title: "Comments",
});
render(<PluginPanelTabContent tab={tab} threadId="thr_9" />);

fireEvent.click(screen.getByRole("button", { name: "reveal:idle" }));
expect(
await screen.findByRole("button", { name: "reveal:revealed" }),
).toBeDefined();
expect(reveal).toHaveBeenCalledWith("msg_1");
unregister();
});

it("contains a throwing run and rejects non-JSON params without opening", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const cyclic: Record<string, unknown> = {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,15 +259,23 @@ function EmbeddedThreadChatHostedFooter({
scrollOverlay,
surface,
}: EmbeddedThreadChatHostedFooterProps) {
const exposePluginTimelineHooks =
surface.includePluginMessageActions !== false;
return (
<div
data-thread-window=""
data-bb-experimental-thread-window={
exposePluginTimelineHooks ? threadId : undefined
}
className="flex h-full min-h-0 min-w-0 flex-col overflow-clip"
>
<PageShell
key={threadId}
scrollBehavior="bottom-anchor"
scrollAnchorThreadId={threadId}
pluginThreadScrollRootId={
exposePluginTimelineHooks ? threadId : undefined
}
shellClassName="!mx-0 !mt-0 md:!mx-0 md:!mt-0"
contentClassName="gap-2 pt-4"
footerClassName="chat-prompt-box"
Expand Down Expand Up @@ -303,6 +311,8 @@ function EmbeddedThreadChatWithComposer({
}: EmbeddedThreadChatComposerModeProps) {
const labels = { ...DEFAULT_LABELS, ...labelOverrides };
const surfaceKey = threadId ?? surfaceFallbackKey ?? "embedded-thread-chat";
const exposePluginTimelineHooks =
threadId !== null && includePluginMessageActions !== false;
const markThreadRead = useMarkThreadRead();
const stopThread = useStopThread();
const sendThreadMessage = useSendThreadMessage();
Expand Down Expand Up @@ -1342,6 +1352,9 @@ function EmbeddedThreadChatWithComposer({
<div
key={surfaceKey}
data-thread-window=""
data-bb-experimental-thread-window={
exposePluginTimelineHooks ? threadId : undefined
}
className="flex min-w-0 flex-col bg-background"
>
<div
Expand All @@ -1359,7 +1372,13 @@ function EmbeddedThreadChatWithComposer({
}

return (
<div data-thread-window="" className="flex min-h-0 flex-1 flex-col">
<div
data-thread-window=""
data-bb-experimental-thread-window={
exposePluginTimelineHooks ? threadId : undefined
}
className="flex min-h-0 flex-1 flex-col"
>
<BottomAnchoredScrollBody
key={surfaceKey}
scrollAreaClassName="bg-background"
Expand All @@ -1369,6 +1388,9 @@ function EmbeddedThreadChatWithComposer({
maxWidthClassName={maxWidthClassName}
footer={footer}
scrollAnchorThreadId={threadId ?? undefined}
pluginThreadScrollRootId={
exposePluginTimelineHooks ? threadId : undefined
}
>
{timelineBody}
</BottomAnchoredScrollBody>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export interface ConversationMessageContentUserProps extends ConversationMessage
initiator: TimelineUserConversationRow["initiator"];
mentions: readonly PromptTextMention[];
onAddToChat?: ThreadTimelineAddToChatHandler;
/** Reports selections from ordinary user-authored message prose. */
onSelectProse?: (selection: MessageProseSelection | null) => void;
resolveMentionLink?: PromptMentionLinkResolver;
resolveSegmentLinkHref?: TimelineTitleLinkResolver;
onOpenLink?: ThreadTimelineLinkHandler;
Expand Down Expand Up @@ -192,6 +194,7 @@ interface UserConversationMessageProps {
mentions: readonly PromptTextMention[];
mobileActionDisplay: "inline" | "overflow";
onAddToChat?: ThreadTimelineAddToChatHandler;
onSelectProse?: (selection: MessageProseSelection | null) => void;
onOpenLink?: ThreadTimelineLinkHandler;
onOpenLocalFileLink?: ThreadTimelineLocalFileLinkHandler;
projectId?: string;
Expand Down Expand Up @@ -385,6 +388,7 @@ function UserConversationMessage({
mentions,
mobileActionDisplay,
onAddToChat,
onSelectProse,
onOpenLink,
onOpenLocalFileLink,
pluginActions = [],
Expand Down Expand Up @@ -484,14 +488,16 @@ function UserConversationMessage({
) : null}
<div className="rounded-xl border border-border-seam bg-surface-recessed px-4 py-2.5 text-sm leading-relaxed text-foreground">
{messageText ? (
<CollapsibleMessageText
mentions={mentions}
resolveMentionLink={resolveMentionLink}
resolveSegmentLinkHref={resolveSegmentLinkHref}
onOpenLink={onOpenLink}
text={text}
mutePrefixLength={mutePrefixLength || undefined}
/>
<SelectableMessageProse onSelect={onSelectProse}>
<CollapsibleMessageText
mentions={mentions}
resolveMentionLink={resolveMentionLink}
resolveSegmentLinkHref={resolveSegmentLinkHref}
onOpenLink={onOpenLink}
text={text}
mutePrefixLength={mutePrefixLength || undefined}
/>
</SelectableMessageProse>
) : (
<p className="text-muted-foreground">Sent attachments</p>
)}
Expand Down Expand Up @@ -727,6 +733,7 @@ export function ConversationMessageContent(
mentions={props.mentions}
mobileActionDisplay={props.mobileActionDisplay ?? "overflow"}
onAddToChat={props.onAddToChat}
onSelectProse={props.onSelectProse}
onOpenLink={props.onOpenLink}
onOpenLocalFileLink={onOpenLocalFileLink}
projectId={projectId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
MULTI_CLICK_SELECTION_REPORT_DELAY_MS,
renderedTextSelectionFromRange,
SelectableMessageProse,
} from "./SelectableMessageProse.js";

Expand All @@ -27,8 +28,19 @@ function makeWindowSelection({
text: string;
}): Selection {
const rect = new DOMRect(10, 20, 30, 8);
const fullText = node.textContent ?? text;
const normalizedText = text.trim();
const selectedStart = Math.max(0, fullText.indexOf(normalizedText));
const selectedEnd = Math.min(
fullText.length,
selectedStart + normalizedText.length,
);
const range = {
commonAncestorContainer: commonAncestorContainer ?? node,
startContainer: node,
startOffset: selectedStart,
endContainer: node,
endOffset: selectedEnd,
getBoundingClientRect: () => rect,
getClientRects: () => ({
length: 1,
Expand Down Expand Up @@ -68,6 +80,37 @@ describe("SelectableMessageProse", () => {
).not.toBeNull();
});

it("captures one UTF-16 rendered-text selector across nested Markdown nodes", () => {
const root = document.createElement("div");
root.append("Before ");
const strong = document.createElement("strong");
strong.textContent = "nested 😀 text";
root.append(strong, " after");
document.body.append(root);
const range = document.createRange();
range.setStart(root.firstChild!, 3);
range.setEnd(strong.firstChild!, "nested 😀".length);
const rect = new DOMRect(10, 20, 90, 18);
vi.spyOn(Range.prototype, "getClientRects").mockReturnValue({
0: rect,
length: 1,
item: (index: number) => (index === 0 ? rect : null),
} as unknown as DOMRectList);
vi.spyOn(Range.prototype, "getBoundingClientRect").mockReturnValue(rect);

expect(renderedTextSelectionFromRange(root, range)).toEqual({
version: 1,
coordinateSpace: "rendered-text-utf16",
start: 3,
end: "Before nested 😀".length,
exact: "ore nested 😀",
prefix: "Bef",
suffix: " text after",
rects: [{ x: 10, y: 20, width: 90, height: 18 }],
});
root.remove();
});

it("reports a selection only after pointer release", async () => {
const onSelect = vi.fn();
const { getByText } = render(
Expand Down
Loading
Loading