#3395 · Compose preview link routing

Bug · Priority: Medium · Effort: Low · ui · 2026-09-10
Base: 40ed14cc7765b7e2d4c37b660eadbf982e5bac5b · GitHub issue

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: medium for the reported symptom; high for the narrower compose routing omission.

1. TL;DR

Compose-panel Markdown previews omit the panel's existing external-link callback. A component click test reproduces that omission for both environment-backed and project-backed files. The same click is handled when routing is explicitly supplied. This does not reproduce the reported complete desktop navigation failure: thread-detail previews already supply routing, and the desktop window factory forwards popup URLs to the system browser. The evidence supports a narrower routing defect, not the claim that every preview link silently fails.

2. Claims vs findings

ClaimFindingEvidence
Preview callers never provide external-link routing.Partially verifiedMissing in root compose file previews; present in thread-detail workspace, host and storage previews.
Clicking an external link does nothing anywhere.UnverifiedTests observe callback omission and uncancelled default behavior, not native navigation. No packaged desktop session was exercised.
The desktop shell has no popup handler.Refuted by sourceThe window factory registers setWindowOpenHandler and calls openExternalUrl before denying the popup.
Every link syntax and relative file path is affected.UnverifiedThe focused test covers one HTTPS Markdown anchor in two compose file-source variants. It does not generalize to mailto or local-file policy.

3. Environment

Trusted origin/main at the full commit above; repository visibility verified public. Darwin arm64, Node v22.22.3, pnpm 9.15.0, Vitest 4.1.1 with jsdom. The installed pnpm launcher was broken; Corepack supplied the repository-pinned pnpm version using a temporary wrapper. Both checkouts completed frozen installs and Turbo builds (20 successful tasks each). No provider, application server, browser, runtime store, port or user credential was used. No visual-layout claim is made, so no screenshot is included.

4. Minimal reproduction

  1. Check out the recorded trusted commit in a fresh clone of get-bb/bb.
  2. Install and build using pnpm install --frozen-lockfile --prefer-offline and pnpm exec turbo run build.
  3. Copy the inline test below to apps/app/src/views/RootComposePanelTabContent.markdown-links.test.tsx.
  4. Run pnpm exec turbo run test --filter=@bb/app -- RootComposePanelTabContent.markdown-links.test.tsx.

The test uses actual RootComposePanelTabContent, preview-tab content, FilePreview, MarkdownPreview and anchor click code. It substitutes query results, unused platform hooks and lazy loading; it does not mock the database or navigation callback under test. The callback spy returns true to model the existing in-app handler accepting a link.

Expected: callback receives the HTTPS href; fireEvent.click returns false.
Actual (both compose variants): Number of calls: 0
Actual default behavior assertion: expected true to be false // Object.is equality
Tests  2 failed | 1 passed (3)

The passing control renders the same workspace preview with an explicit routing callback. No external URL is fetched by this jsdom test.

// @vitest-environment jsdom

import type { ComponentProps } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWorkspaceFilePreviewFixedPanelTab } from "@/lib/fixed-panel-tabs-state";
import { WorkspaceFilePreviewTabContent } from "@/components/secondary-panel/ThreadSecondaryPanelTabContent";
import { RootComposePanelTabContent } from "./RootComposePanelTabContent";

vi.mock("@/components/secondary-panel/lazySecondaryPanelComponents", async () => {
  const content = await import("@/components/secondary-panel/ThreadSecondaryPanelTabContent");
  return {
    LazyWorkspaceFilePreviewTabContent: content.WorkspaceFilePreviewTabContent,
    LazyProjectFilePreviewTabContent: content.ProjectFilePreviewTabContent,
    LazyHostFilePreviewTabContent: content.HostFilePreviewTabContent,
    LazyThreadStorageFilePreviewTabContent: content.ThreadStorageFilePreviewTabContent,
    LazyFilePreview: () => null,
    LazyNewTabPage: () => null,
    LazyThreadTerminalPanel: () => null,
  };
});

function previewQuery() {
  return {
    data: {
      kind: "text",
      content: "[Read documentation](https://example.org/guide)",
      mimeType: "text/markdown",
      name: "guide.md",
      path: "guide.md",
      url: "/content/guide.md",
    },
    error: null,
    isFetching: false,
    isLoading: false,
    refetch: vi.fn(),
  };
}

vi.mock("@/hooks/queries/environment-queries", () => ({
  useEnvironment: () => ({ data: { hostId: "host-preview", path: "/workspace" } }),
  useEnvironmentFilePreview: () => previewQuery(),
  useEnvironmentDiffFiles: vi.fn(),
}));
vi.mock("@/hooks/queries/project-queries", () => ({
  useProjectFilePreview: () => previewQuery(),
}));
vi.mock("@/components/secondary-panel/useThreadStorageViewer", () => ({
  useThreadStorageViewer: () => ({ threadStorageRootPath: null }),
}));
vi.mock("@/hooks/useHostDaemon", () => ({
  useHostDaemon: () => ({ isLocalDaemonHost: () => true }),
}));
vi.mock("@/hooks/useLocalOpenTargets", () => ({
  useLocalOpenTargets: () => ({
    canOpenPreferredFileTarget: false,
    openPathInPreferredFileTarget: vi.fn(),
  }),
}));
vi.mock("@/components/commands/AppCommandProvider", () => ({
  useAppCommandHandler: () => undefined,
  useAppCommandShortcut: () => null,
}));

afterEach(cleanup);

const noop = () => {};
const baseProps = {
  activeTabId: null,
  canCreateTerminal: false,
  currentProjectId: "project-preview",
  isPanelOpen: true,
  isPanelPersistedOpen: true,
  isProjectless: false,
  onActivateTab: noop,
  onAutoFocusNewTabHandled: noop,
  onAutoFocusTerminalHandled: noop,
  onOpenBrowser: noop,
  onSelectFileSearchResult: noop,
  onSelectionAddToChat: noop,
  onStartTerminal: noop,
  pane: { isFocused: true, onFocusPane: noop },
  primaryHostId: "host-preview",
  pluginActions: [],
  projectSources: [],
  projects: [],
  rootPanelEnvironmentId: "env-preview",
  rootPanelThreadId: "thread-preview",
  rootProjectHostId: "host-preview",
  shouldAutoFocusNewTab: false,
  shouldAutoFocusTerminal: false,
  terminalTarget: null,
} satisfies Omit<ComponentProps<typeof RootComposePanelTabContent>, "tab" | "onOpenPanelLink">;

describe("compose file-preview Markdown links", () => {
  it("handles the same click when the caller supplies routing", () => {
    const onOpenLink = vi.fn(() => true);
    render(
      <WorkspaceFilePreviewTabContent
        activePath="guide.md"
        environmentId="env-preview"
        isPanelOpen
        lineRange={null}
        markdownLinkRouting={{ onOpenLink }}
        source={{ kind: "working-tree" }}
        statusLabel={null}
        threadId="thread-preview"
      />,
    );
    const defaultAllowed = fireEvent.click(screen.getByRole("link", { name: "Read documentation" }));
    expect(onOpenLink).toHaveBeenCalledWith({ href: "https://example.org/guide" });
    expect(defaultAllowed).toBe(false);
  });
  it.each(["env-preview", null])("routes an external link with environment %s", (environmentId) => {
    const onOpenPanelLink = vi.fn(() => true);
    const tab = createWorkspaceFilePreviewFixedPanelTab({
      environmentId,
      projectId: "project-preview",
      tab: {
        path: "guide.md",
        lineRange: null,
        source: { kind: "working-tree" },
        statusLabel: null,
      },
    });
    render(<RootComposePanelTabContent {...baseProps} tab={tab} onOpenPanelLink={onOpenPanelLink} />);
    const link = screen.getByRole("link", { name: "Read documentation" });
    const defaultAllowed = fireEvent.click(link);
    expect.soft(onOpenPanelLink).toHaveBeenCalledWith({ href: "https://example.org/guide" });
    expect.soft(defaultAllowed).toBe(false);
  });
});

5. Root cause

RootComposeView creates the preference-aware panel callback, but the file-preview branch drops it. The callback is used for terminal content, while the compose file-preview helper neither accepts it nor provides Markdown routing to its child previews.

    case "thread-storage-file-preview":
      return (
        <RootComposeFilePreviewTabContent
          currentProjectId={currentProjectId}
          fileOpenerSource={null}
          isFocused={pane.isFocused}
          isPanelOpen={isPanelOpen}
          isProjectless={isProjectless}
          onSelectionAddToChat={onSelectionAddToChat}
          primaryHostId={primaryHostId}
          projectSources={projectSources}
          projects={projects}
          rootPanelEnvironmentId={rootPanelEnvironmentId}
          rootPanelThreadId={rootPanelThreadId}
          rootProjectHostId={rootProjectHostId}
          tab={tab}
        />
      );
    case "plugin-panel": {
      const fileOpenerFile = parseFileOpenerParams(tab.paramsJson);
      const originalTab = createFileOpenerOriginalTab(tab);

Compose workspace and project preview construction omits markdownLinkRouting. Image routing builders do not create external navigation handlers. MarkdownAnchor only cancels external default navigation when a supplied callback handles the URL.

    if (
      linkRouting?.onOpenLink &&
      rewrittenHref &&
      linkRouting.onOpenLink({ href: rewrittenHref })
    ) {
      event.preventDefault();
      return;
    }
  };

This explains why compose bypasses the existing panel handler, but cannot explain a complete desktop no-op. Thread-detail preview routing already includes onOpenLink: handleOpenTimelineLink. Desktop popup handling contains:

      browserWindow.webContents.setWindowOpenHandler((details) => {
        args.openExternalUrl({ url: details.url });
        return { action: "deny" };
      });

The desktop implementation calls shell.openExternal. Its runtime success on the reporter's machine remains untested.

6. Proposed fix / next experiment

For the narrower compose omission, pass the existing panel callback through RootComposeFilePreviewTabContent into each preview's Markdown routing, retaining image routing and existing file containment rules. Do not introduce local-file navigation policy as part of this finding. To diagnose the reported total no-op, reproduce in a clean packaged desktop session and record whether the selected preview is a compose or thread-detail pane, whether the callback is reached, and whether the window-open handler and shell.openExternal run.

No production change or PR was made. The autopilot fix gate requires reproduction of the reported bug on trusted main; this investigation establishes only the narrower callback omission. A fix for that omission alone would not justify claiming the desktop failure resolved.

7. Verification

The same agent repeated the complete install, build and test in a second clean detached worktree at 40ed14cc7765b7e2d4c37b660eadbf982e5bac5b. Only the authored regression test was added. The two source checkouts had separate dependency installations and identical unchanged production code. No ports or data directories were needed. Both runs reported two failing compose cases and one passing explicitly routed control. The report was narrowed to PARTIALLY REPRODUCED after checking the already-wired thread-detail path and desktop popup fallback; the second run supported that final scope. This is repeat verification by one agent, not an independent review.

Existing tests: pnpm exec turbo run test --filter=@bb/app -- markdown-preview.test.tsx ThreadSecondaryPanelTabContent.markdown-images.test.tsx RootComposePanelTabContent.test.tsx — 3 files, 26 tests passed.

8. Related issues and PRs

No linked open PR appeared in the issue's cross-reference timeline at investigation time. No linked PR code was checked out or run. This report makes no duplicate determination beyond that metadata check.

9. Appendix

Exact first and second test output follows. The existing suites passed 26 tests in 3 files; both full Turbo builds completed 20 tasks. Evidence is embedded here; raw files remain in the local report backup.

First reproduction

• turbo 2.8.3
• Packages in scope: @bb/app
• Running test in 1 packages
• Remote caching disabled, using shared worktree cache
//:ensure-native-modules: cache bypass, force executing 95374386eed42c81
@bb/templates:generate:plugin-scaffold: cache hit, replaying logs d60aa21218ce3137
@bb/templates:generate:plugin-scaffold: 
@bb/templates:generate:plugin-scaffold: > @bb/templates@0.0.1 generate:plugin-scaffold <trusted-checkout-1>/packages/templates
@bb/templates:generate:plugin-scaffold: > node ./scripts/generate-plugin-scaffold.mjs
@bb/templates:generate:plugin-scaffold: 
@bb/templates:generate:templates: cache hit, replaying logs c16042a1d3129414
@bb/templates:generate:templates: 
@bb/templates:generate:templates: > @bb/templates@0.0.1 generate:templates <trusted-checkout-1>/packages/templates
@bb/templates:generate:templates: > node ./scripts/generate-templates.mjs
@bb/templates:generate:templates: 
//:ensure-native-modules: 
//:ensure-native-modules: > bb@ ensure-native-modules <trusted-checkout-1>
//:ensure-native-modules: > node scripts/ensure-native-modules.mjs
//:ensure-native-modules: 
@bb/app:test: cache miss, executing 327da7eea8853110
@bb/app:test: 
@bb/app:test: > @bb/app@0.0.1 test <trusted-checkout-1>/apps/app
@bb/app:test: > node scripts/generate-pwa-icons.mjs --check && vitest run --config vitest.config.ts "RootComposePanelTabContent.markdown-links.test.tsx"
@bb/app:test: 
@bb/app:test: 
@bb/app:test:  RUN  v4.1.1 <trusted-checkout-1>/apps/app
@bb/app:test: 
@bb/app:test:  ❯ |@bb/app:isolated| src/views/RootComposePanelTabContent.markdown-links.test.tsx (3 tests | 2 failed) 139ms
@bb/app:test:      ✓ handles the same click when the caller supplies routing 93ms
@bb/app:test:      × routes an external link with environment env-preview 28ms
@bb/app:test:      × routes an external link with environment null 18ms
@bb/app:test: 
@bb/app:test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
@bb/app:test: 
@bb/app:test:  FAIL  |@bb/app:isolated| src/views/RootComposePanelTabContent.markdown-links.test.tsx > compose file-preview Markdown links > routes an external link with environment env-preview
@bb/app:test:  FAIL  |@bb/app:isolated| src/views/RootComposePanelTabContent.markdown-links.test.tsx > compose file-preview Markdown links > routes an external link with environment null
@bb/app:test: AssertionError: expected "vi.fn()" to be called with arguments: [ Array(1) ]
@bb/app:test: 
@bb/app:test: Number of calls: 0
@bb/app:test: 
@bb/app:test:  ❯ src/views/RootComposePanelTabContent.markdown-links.test.tsx:129:34
@bb/app:test:     127|     const link = screen.getByRole("link", { name: "Read documentation"…
@bb/app:test:     128|     const defaultAllowed = fireEvent.click(link);
@bb/app:test:     129|     expect.soft(onOpenPanelLink).toHaveBeenCalledWith({ href: "https:/…
@bb/app:test:        |                                  ^
@bb/app:test:     130|     expect.soft(defaultAllowed).toBe(false);
@bb/app:test:     131|   });
@bb/app:test: 
@bb/app:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/4]⎯
@bb/app:test: 
@bb/app:test:  FAIL  |@bb/app:isolated| src/views/RootComposePanelTabContent.markdown-links.test.tsx > compose file-preview Markdown links > routes an external link with environment env-preview
@bb/app:test:  FAIL  |@bb/app:isolated| src/views/RootComposePanelTabContent.markdown-links.test.tsx > compose file-preview Markdown links > routes an external link with environment null
@bb/app:test: AssertionError: expected true to be false // Object.is equality
@bb/app:test: 
@bb/app:test: - Expected
@bb/app:test: + Received
@bb/app:test: 
@bb/app:test: - false
@bb/app:test: + true
@bb/app:test: 
@bb/app:test:  ❯ src/views/RootComposePanelTabContent.markdown-links.test.tsx:130:33
@bb/app:test:     128|     const defaultAllowed = fireEvent.click(link);
@bb/app:test:     129|     expect.soft(onOpenPanelLink).toHaveBeenCalledWith({ href: "https:/…
@bb/app:test:     130|     expect.soft(defaultAllowed).toBe(false);
@bb/app:test:        |                                 ^
@bb/app:test:     131|   });
@bb/app:test:     132| });
@bb/app:test: 
@bb/app:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/4]⎯
@bb/app:test: 
@bb/app:test: 
@bb/app:test:  Test Files  1 failed (1)
@bb/app:test:       Tests  2 failed | 1 passed (3)
@bb/app:test:    Start at  01:13:17
@bb/app:test:    Duration  2.40s (transform 1.09s, setup 87ms, import 1.73s, tests 139ms, environment 352ms)
@bb/app:test: 
@bb/app:test:  ELIFECYCLE  Test failed. See above for more details.
@bb/app:test: ERROR: command finished with error: command (<trusted-checkout-1>/apps/app) pnpm run test RootComposePanelTabContent.markdown-links.test.tsx exited (1)
@bb/app#test: command (<trusted-checkout-1>/apps/app) pnpm run test RootComposePanelTabContent.markdown-links.test.tsx exited (1)

 Tasks:    3 successful, 4 total
Cached:    2 cached, 4 total
  Time:    3.89s 
Failed:    @bb/app#test

 ERROR  run failed: command  exited (1)

Second reproduction

• turbo 2.8.3
• Packages in scope: @bb/app
• Running test in 1 packages
• Remote caching disabled, using shared worktree cache
//:ensure-native-modules: cache bypass, force executing 95374386eed42c81
@bb/templates:generate:templates: cache hit, replaying logs c16042a1d3129414
@bb/templates:generate:plugin-scaffold: cache hit, replaying logs d60aa21218ce3137
@bb/templates:generate:templates: 
@bb/templates:generate:templates: > @bb/templates@0.0.1 generate:templates <trusted-checkout-1>/packages/templates
@bb/templates:generate:templates: > node ./scripts/generate-templates.mjs
@bb/templates:generate:templates: 
@bb/templates:generate:plugin-scaffold: 
@bb/templates:generate:plugin-scaffold: > @bb/templates@0.0.1 generate:plugin-scaffold <trusted-checkout-1>/packages/templates
@bb/templates:generate:plugin-scaffold: > node ./scripts/generate-plugin-scaffold.mjs
@bb/templates:generate:plugin-scaffold: 
//:ensure-native-modules: 
//:ensure-native-modules: > bb@ ensure-native-modules <trusted-checkout-2>
//:ensure-native-modules: > node scripts/ensure-native-modules.mjs
//:ensure-native-modules: 
@bb/app:test: cache miss, executing 327da7eea8853110
@bb/app:test: 
@bb/app:test: > @bb/app@0.0.1 test <trusted-checkout-2>/apps/app
@bb/app:test: > node scripts/generate-pwa-icons.mjs --check && vitest run --config vitest.config.ts "RootComposePanelTabContent.markdown-links.test.tsx"
@bb/app:test: 
@bb/app:test: 
@bb/app:test:  RUN  v4.1.1 <trusted-checkout-2>/apps/app
@bb/app:test: 
@bb/app:test:  ❯ |@bb/app:isolated| src/views/RootComposePanelTabContent.markdown-links.test.tsx (3 tests | 2 failed) 128ms
@bb/app:test:      ✓ handles the same click when the caller supplies routing 86ms
@bb/app:test:      × routes an external link with environment env-preview 24ms
@bb/app:test:      × routes an external link with environment null 17ms
@bb/app:test: 
@bb/app:test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
@bb/app:test: 
@bb/app:test:  FAIL  |@bb/app:isolated| src/views/RootComposePanelTabContent.markdown-links.test.tsx > compose file-preview Markdown links > routes an external link with environment env-preview
@bb/app:test:  FAIL  |@bb/app:isolated| src/views/RootComposePanelTabContent.markdown-links.test.tsx > compose file-preview Markdown links > routes an external link with environment null
@bb/app:test: AssertionError: expected "vi.fn()" to be called with arguments: [ Array(1) ]
@bb/app:test: 
@bb/app:test: Number of calls: 0
@bb/app:test: 
@bb/app:test:  ❯ src/views/RootComposePanelTabContent.markdown-links.test.tsx:129:34
@bb/app:test:     127|     const link = screen.getByRole("link", { name: "Read documentation"…
@bb/app:test:     128|     const defaultAllowed = fireEvent.click(link);
@bb/app:test:     129|     expect.soft(onOpenPanelLink).toHaveBeenCalledWith({ href: "https:/…
@bb/app:test:        |                                  ^
@bb/app:test:     130|     expect.soft(defaultAllowed).toBe(false);
@bb/app:test:     131|   });
@bb/app:test: 
@bb/app:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/4]⎯
@bb/app:test: 
@bb/app:test:  FAIL  |@bb/app:isolated| src/views/RootComposePanelTabContent.markdown-links.test.tsx > compose file-preview Markdown links > routes an external link with environment env-preview
@bb/app:test:  FAIL  |@bb/app:isolated| src/views/RootComposePanelTabContent.markdown-links.test.tsx > compose file-preview Markdown links > routes an external link with environment null
@bb/app:test: AssertionError: expected true to be false // Object.is equality
@bb/app:test: 
@bb/app:test: - Expected
@bb/app:test: + Received
@bb/app:test: 
@bb/app:test: - false
@bb/app:test: + true
@bb/app:test: 
@bb/app:test:  ❯ src/views/RootComposePanelTabContent.markdown-links.test.tsx:130:33
@bb/app:test:     128|     const defaultAllowed = fireEvent.click(link);
@bb/app:test:     129|     expect.soft(onOpenPanelLink).toHaveBeenCalledWith({ href: "https:/…
@bb/app:test:     130|     expect.soft(defaultAllowed).toBe(false);
@bb/app:test:        |                                 ^
@bb/app:test:     131|   });
@bb/app:test:     132| });
@bb/app:test: 
@bb/app:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/4]⎯
@bb/app:test: 
@bb/app:test: 
@bb/app:test:  Test Files  1 failed (1)
@bb/app:test:       Tests  2 failed | 1 passed (3)
@bb/app:test:    Start at  01:13:32
@bb/app:test:    Duration  2.34s (transform 887ms, setup 84ms, import 1.49s, tests 128ms, environment 545ms)
@bb/app:test: 
@bb/app:test:  ELIFECYCLE  Test failed. See above for more details.
@bb/app:test: ERROR: command finished with error: command (<trusted-checkout-2>/apps/app) pnpm run test RootComposePanelTabContent.markdown-links.test.tsx exited (1)
@bb/app#test: command (<trusted-checkout-2>/apps/app) pnpm run test RootComposePanelTabContent.markdown-links.test.tsx exited (1)

 Tasks:    3 successful, 4 total
Cached:    2 cached, 4 total
  Time:    4.349s 
Failed:    @bb/app#test

 ERROR  run failed: command  exited (1)

Local paths are replaced with descriptive labels. Issue content was treated only as untrusted claims; no issue-supplied commands, patches or URLs were executed or fetched. The test was authored from trusted repository code. No application processes were started, so no runtime cleanup was necessary.

AGENT GENERATED