Skip to content
Merged
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
8 changes: 5 additions & 3 deletions .bb/skills/verify-bb/features/plugin-inline-vis.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ SKILL.md. Inspect nested `--help` before selecting flags and IDs.

| Feature | Drive | Observable success |
| --- | --- | --- |
| Render and open | Emit the directive with a relative .html/.htm path, open its header file action, and reload the thread. | The file renders inline and opens from the correct thread workspace. |
| Render and open | Emit the directive with a relative .html/.htm path, open the action that appears over the page on hover, and reload the thread. | The file renders inline with no card or border, follows the theme, and opens from the correct thread workspace. |
| Fenced block | Have the reply contain an `inline-vis` fenced code block whose body is an HTML page; watch it stream, then use the hover action. | A status line shows while the block is written; the page appears when the fence closes and is not reloaded as the reply continues; the action reveals the source; an unclosed block stays a code block. |
| Relative assets | Reference a fixture stylesheet, script, and image beside the HTML. | Assets resolve from the authorized workspace location and render without unrelated filesystem access. |
| Height | Try omitted height and values around the declared 120–1200 bounds. | Omitted height is 224px; whole numbers from 120 to 1200 are accepted. Invalid and out-of-range values show an explicit error instead of clamping. |
| Height | Try omitted height and values around the declared 80–2000 bounds. | With no height an HTML frame fits its page and a Markdown card previews 256px, expanding in place with "Show more"; whole numbers from 80 to 2000 are accepted (a cap for HTML, the preview height for Markdown). Invalid and out-of-range values show an explicit error instead of clamping. |
| Sandbox | Have the fixture attempt to read parent DOM, cookies, and origin storage; report only success/failure. | The sandbox blocks parent-origin access while allowing the intended visualization interaction. |
| Invalid artifacts | Try missing, non-HTML, invalid UTF-8, and greater-than-5MiB fixtures. | Clear artifact errors replace the frame; no silent blank success or unrestricted fallback occurs. |

Expand All @@ -39,4 +40,5 @@ External account changes use authorized disposable targets.

## Maintenance notes

- Omitted height uses 224px. Whole-number heights from 120 through 1200 are accepted; out-of-range or invalid values show an explicit height error rather than clamping. Source: `plugins/inline-vis/app.tsx:17`, `plugins/inline-vis/app.tsx:30`.
- An HTML frame fits its page (80–2000px) and `height` caps it; a Markdown card previews 256px and expands in place. Out-of-range or invalid values show an explicit height error rather than clamping. Source: `plugins/inline-vis/frame.ts` (`FRAME_MIN_HEIGHT`, `FRAME_MAX_HEIGHT`, `frameHeight`), `plugins/inline-vis/app.tsx` (`parseRequestedHeight`).
- The borderless frame, the 80–2000 bounds, the fenced-block recipe, and the expanding Markdown card date from 2026-10-12 and are not covered by the 2026-09-05 validation run.
47 changes: 40 additions & 7 deletions apps/app/src/components/plugin/PluginSlotMount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@ import {
type PluginSlotOwnershipRegistry,
} from "./plugin-context";

const crashedSlotInstances = new Set<string>();
const crashedSlotInstances = new Map<string, string | undefined>();

function isSlotInstanceCrashed(
instanceKey: string,
resetKey: string | undefined,
): boolean {
return (
crashedSlotInstances.has(instanceKey) &&
crashedSlotInstances.get(instanceKey) === resetKey
);
}
const ownedStateReleasesBySlotInstance = new Map<
string,
Map<symbol, () => void>
Expand Down Expand Up @@ -55,7 +65,7 @@ function pluginSlotInstanceKey(

export function resetCrashedPluginSlots(pluginId: string): void {
const prefix = `${pluginId}/`;
for (const key of [...crashedSlotInstances]) {
for (const key of [...crashedSlotInstances.keys()]) {
if (key.startsWith(prefix)) crashedSlotInstances.delete(key);
}
}
Expand All @@ -77,11 +87,13 @@ interface PluginSlotBoundaryProps {
instanceKey: string;
children: ReactNode;
fallback?: ReactNode;
resetKey?: string;
onCrash?: (pluginId: string) => void;
}

interface PluginSlotBoundaryState {
crashed: boolean;
resetKey: string | undefined;
}

class PluginSlotBoundary extends Component<
Expand All @@ -103,19 +115,37 @@ class PluginSlotBoundary extends Component<

constructor(props: PluginSlotBoundaryProps) {
super(props);
this.state = { crashed: crashedSlotInstances.has(props.instanceKey) };
this.state = {
crashed: isSlotInstanceCrashed(props.instanceKey, props.resetKey),
resetKey: props.resetKey,
};
}

static getDerivedStateFromError(): PluginSlotBoundaryState {
static getDerivedStateFromError(): Pick<PluginSlotBoundaryState, "crashed"> {
return { crashed: true };
}

static getDerivedStateFromProps(
props: PluginSlotBoundaryProps,
state: PluginSlotBoundaryState,
): PluginSlotBoundaryState | null {
if (props.resetKey === state.resetKey) {
return null;
}
return {
crashed: isSlotInstanceCrashed(props.instanceKey, props.resetKey),
resetKey: props.resetKey,
};
}

override componentDidCatch(error: Error, info: ErrorInfo): void {
releaseSlotInstanceOwnedState(this.props.instanceKey);
this.ownedStateReleases.clear();
crashedSlotInstances.add(this.props.instanceKey);
crashedSlotInstances.set(this.props.instanceKey, this.props.resetKey);
console.warn(
`[plugin:${this.props.pluginId}] slot "${this.props.instanceKey}" crashed and is disabled for this session: ${error.message}`,
this.props.resetKey === undefined
? `[plugin:${this.props.pluginId}] slot "${this.props.instanceKey}" crashed and is disabled for this session: ${error.message}`
: `[plugin:${this.props.pluginId}] slot "${this.props.instanceKey}" crashed and is disabled until its input changes: ${error.message}`,
info.componentStack,
);
try {
Expand Down Expand Up @@ -144,7 +174,7 @@ class PluginSlotBoundary extends Component<
override render(): ReactNode {
if (
this.state.crashed ||
crashedSlotInstances.has(this.props.instanceKey)
isSlotInstanceCrashed(this.props.instanceKey, this.props.resetKey)
) {
return this.props.fallback === undefined ? (
<CrashedPluginChip pluginId={this.props.pluginId} />
Expand All @@ -166,6 +196,7 @@ interface PluginSlotMountProps {
slotId: string;
children: ReactNode;
crashFallback?: ReactNode;
crashResetKey?: string;
instanceId?: string;
onCrash?: (pluginId: string) => void;
}
Expand All @@ -176,6 +207,7 @@ export function PluginSlotMount({
slotId,
children,
crashFallback,
crashResetKey,
instanceId,
onCrash,
}: PluginSlotMountProps) {
Expand All @@ -192,6 +224,7 @@ export function PluginSlotMount({
instanceId,
)}
fallback={crashFallback}
{...(crashResetKey === undefined ? {} : { resetKey: crashResetKey })}
{...(onCrash ? { onCrash } : {})}
>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ import {
useMessageDirectiveRegistry,
type MarkdownMessageDirectives,
} from "@/components/ui/markdown-message-directives.js";
import {
CodeFenceRendererHost,
useCodeFenceRendererRegistry,
} from "@/components/ui/markdown-code-fence-renderers.js";
import {
boundedMarkdownPreview,
closeUnterminatedMarkdownCodeSpan,
Expand Down Expand Up @@ -585,6 +589,43 @@ function AssistantConversationMessage({
onOpenPluginPanel,
]);

const codeFenceRendererRegistry = useCodeFenceRendererRegistry();
const codeFenceMessage = useMemo(
() => ({ id, threadId, turnId, projectId: projectId ?? null }),
[id, threadId, turnId, projectId],
);
const markdown = (
<>
<MarkdownPreview
allowHtml
className={
streamingSplit === null
? undefined
: STREAMING_SETTLED_MARKDOWN_CLASS_NAME
}
content={
streamingSplit === null ? liveMarkdown : streamingSplit.settled
}
incrementalBlocks
linkRouting={linkRouting}
messageDirectives={messageDirectives}
threadMentions={ASSISTANT_THREAD_MENTIONS}
/>
{streamingSplit === null ? null : (
<MarkdownPreview
allowHtml
className={STREAMING_TAIL_MARKDOWN_CLASS_NAME}
content={liveMarkdown}
sourcePrefix={streamingSplit.settled}
incrementalBlocks
linkRouting={linkRouting}
messageDirectives={messageDirectives}
threadMentions={ASSISTANT_THREAD_MENTIONS}
/>
)}
</>
);

return (
<div
className={cn(
Expand All @@ -594,32 +635,17 @@ function AssistantConversationMessage({
data-message-column=""
>
<SelectableMessageProse onSelect={onSelectProse}>
<MarkdownPreview
allowHtml
className={
streamingSplit === null
? undefined
: STREAMING_SETTLED_MARKDOWN_CLASS_NAME
}
content={
streamingSplit === null ? liveMarkdown : streamingSplit.settled
}
incrementalBlocks
linkRouting={linkRouting}
messageDirectives={messageDirectives}
threadMentions={ASSISTANT_THREAD_MENTIONS}
/>
{streamingSplit === null ? null : (
<MarkdownPreview
allowHtml
className={STREAMING_TAIL_MARKDOWN_CLASS_NAME}
content={liveMarkdown}
sourcePrefix={streamingSplit.settled}
incrementalBlocks
linkRouting={linkRouting}
messageDirectives={messageDirectives}
threadMentions={ASSISTANT_THREAD_MENTIONS}
/>
{codeFenceRendererRegistry === null ? (
markdown
) : (
<CodeFenceRendererHost
registry={codeFenceRendererRegistry}
message={codeFenceMessage}
openThreadPanel={onOpenPluginPanel ?? null}
streaming={streaming}
>
{markdown}
</CodeFenceRendererHost>
)}
</SelectableMessageProse>
<ConversationAttachments
Expand Down
129 changes: 72 additions & 57 deletions apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ import {
buildMessageDirectiveRegistry,
MessageDirectiveRegistryProvider,
} from "@/components/ui/markdown-message-directives.js";
import {
buildCodeFenceRendererRegistry,
CodeFenceRendererRegistryProvider,
} from "@/components/ui/markdown-code-fence-renderers.js";
import {
TimelineWindowedItemsLoader,
TimelineWindowingMeasurementsContext,
Expand Down Expand Up @@ -2289,6 +2293,11 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
() => getPluginSlotSnapshot().messageDirectives,
() => EMPTY_PLUGIN_SLOT_SNAPSHOT.messageDirectives,
);
const codeFenceRendererSlots = useSyncExternalStore(
subscribePluginSlots,
() => getPluginSlotSnapshot().codeFenceRenderers,
() => EMPTY_PLUGIN_SLOT_SNAPSHOT.codeFenceRenderers,
);
const messageActionSlots = useSyncExternalStore(
subscribePluginSlots,
() => getPluginSlotSnapshot().messageActions,
Expand All @@ -2298,6 +2307,10 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
() => buildMessageDirectiveRegistry(messageDirectiveSlots),
[messageDirectiveSlots],
);
const codeFenceRendererRegistry = useMemo(
() => buildCodeFenceRendererRegistry(codeFenceRendererSlots),
[codeFenceRendererSlots],
);
const onSelectionAddToChat = props.onSelectionAddToChat;
const timelineThreadId = props.threadId;
const hasPluginSelectionActions =
Expand Down Expand Up @@ -2447,70 +2460,72 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
);

return (
<SearchMessageLocationProvider threadId={props.threadId}>
<MessageDirectiveRegistryProvider registry={messageDirectiveRegistry}>
<TimelineRendererStaticContext.Provider value={staticContextValue}>
<SenderThreadMetadataContext.Provider
value={senderThreadMetadataById}
>
<LatestActionableAssistantMessageIdContext.Provider
value={latestActionableAssistantMessageId}
<CodeFenceRendererRegistryProvider registry={codeFenceRendererRegistry}>
<SearchMessageLocationProvider threadId={props.threadId}>
<MessageDirectiveRegistryProvider registry={messageDirectiveRegistry}>
<TimelineRendererStaticContext.Provider value={staticContextValue}>
<SenderThreadMetadataContext.Provider
value={senderThreadMetadataById}
>
<LatestActionableUserMessageIdContext.Provider
value={latestActionableUserMessageId}
<LatestActionableAssistantMessageIdContext.Provider
value={latestActionableAssistantMessageId}
>
<StreamingAssistantMessageIdContext.Provider
value={streamingAssistantMessageId}
<LatestActionableUserMessageIdContext.Provider
value={latestActionableUserMessageId}
>
<TimelineTurnStateContext.Provider
value={turnStateContextValue}
<StreamingAssistantMessageIdContext.Provider
value={streamingAssistantMessageId}
>
<TimelineWindowingMeasurementsContext.Provider
value={windowingMeasurements}
<TimelineTurnStateContext.Provider
value={turnStateContextValue}
>
<AutoHeightContainer
snapRevision={heightSnapRevision}
animateGrowth={!scopeActive}
<TimelineWindowingMeasurementsContext.Provider
value={windowingMeasurements}
>
<TimelineRowsList
hasOlderTimelineRows={props.hasOlderTimelineRows}
isLoadingOlderTimelineRows={
props.isLoadingOlderTimelineRows
}
navigationTargetRowId={
props.timelineNavigationTargetRowId
}
onLoadOlderRows={props.onLoadOlderRows}
rows={rows}
scopeActive={scopeActive}
showAssistantMessageActions={true}
compactActivityIntents={false}
spacing="top-level"
unreadDividerAutoScroll={
props.unreadDividerAutoScroll ?? true
}
unreadDividerPlacement={
props.unreadDividerPlacement ?? null
}
<AutoHeightContainer
snapRevision={heightSnapRevision}
animateGrowth={!scopeActive}
>
<TimelineRowsList
hasOlderTimelineRows={props.hasOlderTimelineRows}
isLoadingOlderTimelineRows={
props.isLoadingOlderTimelineRows
}
navigationTargetRowId={
props.timelineNavigationTargetRowId
}
onLoadOlderRows={props.onLoadOlderRows}
rows={rows}
scopeActive={scopeActive}
showAssistantMessageActions={true}
compactActivityIntents={false}
spacing="top-level"
unreadDividerAutoScroll={
props.unreadDividerAutoScroll ?? true
}
unreadDividerPlacement={
props.unreadDividerPlacement ?? null
}
/>
</AutoHeightContainer>
</TimelineWindowingMeasurementsContext.Provider>
{hasSelectionActions ? (
<TimelineSelectionMenu
selection={activeSelection?.selection ?? null}
onAddToChat={selectionAddToChatHandler}
pluginActions={selectionPluginActions}
onDismiss={dismissSelection}
/>
</AutoHeightContainer>
</TimelineWindowingMeasurementsContext.Provider>
{hasSelectionActions ? (
<TimelineSelectionMenu
selection={activeSelection?.selection ?? null}
onAddToChat={selectionAddToChatHandler}
pluginActions={selectionPluginActions}
onDismiss={dismissSelection}
/>
) : null}
</TimelineTurnStateContext.Provider>
</StreamingAssistantMessageIdContext.Provider>
</LatestActionableUserMessageIdContext.Provider>
</LatestActionableAssistantMessageIdContext.Provider>
</SenderThreadMetadataContext.Provider>
</TimelineRendererStaticContext.Provider>
</MessageDirectiveRegistryProvider>
</SearchMessageLocationProvider>
) : null}
</TimelineTurnStateContext.Provider>
</StreamingAssistantMessageIdContext.Provider>
</LatestActionableUserMessageIdContext.Provider>
</LatestActionableAssistantMessageIdContext.Provider>
</SenderThreadMetadataContext.Provider>
</TimelineRendererStaticContext.Provider>
</MessageDirectiveRegistryProvider>
</SearchMessageLocationProvider>
</CodeFenceRendererRegistryProvider>
);
}

Expand Down
Loading
Loading