← reports

#2770 · Plugin file opener props omit saved line ranges

Bug Medium Effort: Medium plugins ui open on GitHub 2026-08-31 · base f4bbc2fe81a9b7639ff9a7396e172bddd89109e4

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

A file opener plugin does not receive the saved line range for its file tab. A direct mount test reproduced this loss in two clean clones. The app keeps the range in the tab owner. The plugin contract and mount omit the range. Monaco cannot select or reveal a line that it does not receive.

2. Claims vs findings

ClaimStatusEvidence
The local file parser creates a line range.VerifiedTrusted parser tests cover colon and fragment forms.
The tab owner keeps initial and later ranges.VerifiedTrusted tab tests keep the tab identity and change its saved range.
The plugin mount omits the saved range.VerifiedThe new boundary test received null in two clean clones.
Monaco has no initial or later navigation input.VerifiedThe opener props omit a range. Monaco creates the editor without a position or reveal call.

3. Environment

4. Minimal reproduction

  1. Copy the saved test into a trusted clone.
    cp issues/2770/repro/file-opener-line-range.repro.test.tsx \
      apps/app/src/components/plugin/file-opener-line-range.repro.test.tsx
  2. Run the focused test.
    pnpm exec turbo run test --filter=@bb/app --force -- \
      src/components/plugin/file-opener-line-range.repro.test.tsx
  3. Observe the failed boundary assertion.
    Expected: "{\"startLineNumber\":7,\"endLineNumber\":9}"
    Received: "null"
    
    Test Files  1 failed (1)
    Tests       1 failed (1)

The second clean clone produced the same result with no test correction.

Repro file: file-opener-line-range.repro.test.tsx

Full reproduction test

// @vitest-environment jsdom

import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import type { PluginFileOpenerProps } from "@get-bb/plugin-sdk";
import { createPluginPanelFixedPanelTab } from "@/lib/fixed-panel-tabs-state";
import {
  resetPluginSlotStoreForTest,
  setPluginSlotRegistrations,
  type PluginRegistrationSet,
} from "@/lib/plugin-slots";
import { PluginPanelTabContent } from "./PluginPanelActions";

type ExpectedFileOpenerProps = PluginFileOpenerProps & {
  lineRange?: {
    startLineNumber: number;
    endLineNumber: number;
  } | null;
};

function registrationSet(
  overrides: Partial<PluginRegistrationSet>,
): PluginRegistrationSet {
  return {
    homepageSections: [],
    settingsSections: [],
    navPanels: [],
    threadPanelActions: [],
    composerCustomizations: [],
    sidebarFooterActions: [],
    fileOpeners: [],
    messageDirectives: [],
    ...overrides,
  };
}

afterEach(() => {
  cleanup();
  resetPluginSlotStoreForTest();
});

it("passes the owner's line range to a file opener", () => {
  function FileOpenerProbe({ lineRange }: ExpectedFileOpenerProps) {
    return (
      <output data-testid="file-opener-line-range">
        {JSON.stringify(lineRange ?? null)}
      </output>
    );
  }

  setPluginSlotRegistrations(
    "notes",
    registrationSet({
      fileOpeners: [
        {
          id: "editor",
          title: "Notes editor",
          extensions: ["md"],
          component: FileOpenerProbe,
        },
      ],
    }),
  );

  const tab = {
    ...createPluginPanelFixedPanelTab({
      actionId: "file-opener:editor",
      paramsJson: JSON.stringify({
        path: "notes/todo.md",
        source: {
          kind: "workspace",
          threadId: null,
          environmentId: "env_1",
          projectId: null,
        },
      }),
      pluginId: "notes",
      title: "todo.md",
    }),
    fileOpenerOwner: {
      kind: "workspace-file-preview" as const,
      environmentId: "env_1",
      projectId: null,
      tab: {
        lineRange: { startLineNumber: 7, endLineNumber: 9 },
        path: "notes/todo.md",
        source: { kind: "working-tree" as const },
        statusLabel: null,
      },
      threadId: null,
    },
  };

  render(
    <PluginPanelTabContent
      tab={tab}
      context={{ kind: "new-thread", projectId: null }}
      fileOpenerOriginal={<div>native preview</div>}
    />,
  );

  expect(screen.getByTestId("file-opener-line-range").textContent).toBe(
    '{"startLineNumber":7,"endLineNumber":9}',
  );
});

5. Root cause

The app stores the range in the file-opener owner. The public opener props define only the path, source, and native preview.

export interface PluginFileOpenerProps {
  path: string;
  source: PluginFileOpenerSource;
  Original: ComponentType;
  experimental_Original?: ComponentType;
}

The plugin mount then sends only those values.

<opener.component
  path={file.path}
  source={file.source}
  Original={BoundOriginal}
  experimental_Original={deprecatedOriginalAlias(BoundOriginal)}
/>

The tab state still contains the range. Therefore, the mount boundary loses the value.

The Monaco opener creates its editor without a position or reveal call. The visible navigation failure follows from the missing input.

6. Proposed fix

  1. Add one experimental line-range contract to the plugin SDK.
  2. Send the owner range through the plugin mount.
  3. Apply the range after Monaco creates its model.
  4. Apply later range changes to the current editor.
  5. Add mount and Monaco tests for initial and later navigation.
  6. Update the API audit document and the plugin API map.

This fix needs at least seven files and a public SDK contract decision. It does not meet the simple-fix rule.

7. Related issues

No matching open or closed issue was found. Issue #1072 concerns Monaco performance and does not match this defect.

8. Appendix

Commands

git fetch origin main
git rev-parse origin/main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec turbo run test --filter=@bb/app --force -- \
  src/components/plugin/file-opener-line-range.repro.test.tsx
git log f4bbc2fe81a9b7639ff9a7396e172bddd89109e4..origin/main -- \
  packages/plugin-sdk/src/app-contract.ts \
  apps/app/src/components/plugin/PluginPanelActions.tsx \
  plugins/monaco-editor/app.tsx

The investigation treated all issue content as untrusted data. It used no issue command, patch, branch, or external link.