From 8ba55a8d21b1a12240274c7feb294086a1f7cb19 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 18 Aug 2026 22:22:18 -0700 Subject: [PATCH 01/18] Add shared plugin URL navigation --- apps/app/src/App.tsx | 27 ++-- .../plugin/ExperimentalUrlLink.test.tsx | 65 ++++++++ .../components/plugin/ExperimentalUrlLink.tsx | 63 ++++++++ .../plugin/PluginPanelRightPanelHost.tsx | 7 +- .../thread/terminal/ThreadTerminalView.tsx | 12 +- .../app/src/components/tools/PluginDetail.tsx | 7 +- .../components/ui/markdown-preview.test.tsx | 7 +- .../src/components/ui/markdown-preview.tsx | 14 +- apps/app/src/lib/app-navigation-host.tsx | 57 +++++++ apps/app/src/lib/plugin-sdk-app-impl.test.tsx | 41 +++-- apps/app/src/lib/plugin-sdk-app-impl.tsx | 18 ++- apps/app/src/lib/plugin-sdk-hooks.ts | 16 +- apps/app/src/lib/url-open-routing.tsx | 26 +++- apps/app/src/views/RootComposeView.tsx | 142 +++++++++--------- .../bb-plugin-authoring/SKILL.md | 13 +- docs/api_to_audit.md | 24 +++ packages/plugin-sdk/README.md | 7 + packages/plugin-sdk/src/app-contract.ts | 25 ++- packages/plugin-sdk/src/app.ts | 1 + .../testing/__tests__/app-harness.test.tsx | 36 +++++ packages/plugin-sdk/src/testing/app.tsx | 46 +++++- .../src/templates/bb-guide-plugins.md | 10 +- plugins/github/app.tsx | 33 ++-- 23 files changed, 552 insertions(+), 145 deletions(-) create mode 100644 apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx create mode 100644 apps/app/src/components/plugin/ExperimentalUrlLink.tsx create mode 100644 apps/app/src/lib/app-navigation-host.tsx diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 9a25e277ed..66c7d6698a 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -10,6 +10,7 @@ import { AppLayout } from "./components/layout/AppLayout"; import { AuthCallbackView } from "./views/AuthCallbackView"; import { QuickCreateProjectProvider } from "./hooks/useQuickCreateProject"; import { RouteNavigationProvider } from "./components/ui/app-route-anchor"; +import { AppNavigationUrlHost } from "./lib/url-open-routing"; import { useAppTheme } from "./hooks/useAppTheme"; import { useFaviconColorSync } from "./lib/favicon-color-preference"; import { useDesktopThemeSync } from "./hooks/useDesktopThemeSync"; @@ -384,18 +385,20 @@ export function App() { - - - } - /> - } /> - - {/* Outside : a provider CLI install outlives the page that - started it, so its failure toast can be clicked from any route — - including auth callback, which renders no app shell. */} - + + + + } + /> + } /> + + {/* Outside : a provider CLI install outlives the page that + started it, so its failure toast can be clicked from any route — + including auth callback, which renders no app shell. */} + + diff --git a/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx b/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx new file mode 100644 index 0000000000..d97966ca33 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { RouteNavigationProvider } from "@/components/ui/app-route-anchor"; +import { AppNavigationHostProvider } from "@/lib/app-navigation-host"; +import { ExperimentalUrlLink } from "./ExperimentalUrlLink"; + +afterEach(cleanup); + +describe("ExperimentalUrlLink", () => { + it("sends an ordinary web activation to the navigation host", () => { + const openUrl = vi.fn(() => true); + render( + + + + + Example + + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "Example" })); + expect(openUrl).toHaveBeenCalledWith({ url: "https://example.com" }); + }); + + it("leaves modifier clicks native", () => { + const openUrl = vi.fn(() => true); + render( + + + + Example + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "Example" }), { + metaKey: true, + }); + expect(openUrl).not.toHaveBeenCalled(); + }); + + it("routes internal links through browser history before URL preferences", () => { + const openUrl = vi.fn(() => true); + render( + + + + Settings + + Settings route} /> + + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "Settings" })); + expect(screen.getByText("Settings route")).toBeTruthy(); + expect(openUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/plugin/ExperimentalUrlLink.tsx b/apps/app/src/components/plugin/ExperimentalUrlLink.tsx new file mode 100644 index 0000000000..3ac1984d24 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalUrlLink.tsx @@ -0,0 +1,63 @@ +import { useCallback, type MouseEvent as ReactMouseEvent } from "react"; +import type { ExperimentalUrlLinkProps } from "@get-bb/plugin-sdk"; +import { RouteAnchor } from "@/components/ui/app-route-anchor"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { resolveRouteHref } from "@/lib/route-paths"; + +function shouldHandleUrlClick( + event: ReactMouseEvent, +): boolean { + if ( + event.defaultPrevented || + event.button !== 0 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.currentTarget.hasAttribute("download") + ) { + return false; + } + return true; +} + +function isCurrentAppRoute(href: string): boolean { + return ( + typeof window !== "undefined" && + resolveRouteHref({ currentOrigin: window.location.origin, href }) !== null + ); +} + +/** Host-rendered URL link shared by plugins and first-party app surfaces. */ +export function ExperimentalUrlLink({ + href, + onClick, + rel, + target, + ...anchorProps +}: ExperimentalUrlLinkProps) { + const navigation = useAppNavigationHost(); + const handleClick = useCallback( + (event: ReactMouseEvent) => { + onClick?.(event); + if ( + !shouldHandleUrlClick(event) || + isCurrentAppRoute(href) || + !navigation.openUrl({ url: href }) + ) { + return; + } + event.preventDefault(); + }, + [href, navigation, onClick], + ); + return ( + + ); +} diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx index 0c7a127d7a..caeedccd1f 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx @@ -56,6 +56,7 @@ import { } from "@/lib/bb-desktop"; import { getBrowserUrlHost } from "@/lib/browser-url"; import { isRoutePath } from "@/lib/route-paths"; +import { UrlOpenRoutingProvider } from "@/lib/url-open-routing"; import { usePluginSlots } from "@/lib/plugin-slots"; import { useOptionalPaneContext } from "@/views/thread-detail/PaneContext"; import { @@ -670,7 +671,9 @@ export function PluginPanelRightPanelHost({ ); return ( - <> + {panel !== null && togglePortalTarget !== null && !isOpen && @@ -696,6 +699,6 @@ export function PluginPanelRightPanelHost({ ) : null} {page} - + ); } diff --git a/apps/app/src/components/thread/terminal/ThreadTerminalView.tsx b/apps/app/src/components/thread/terminal/ThreadTerminalView.tsx index c62c4032e7..8ca6998f17 100644 --- a/apps/app/src/components/thread/terminal/ThreadTerminalView.tsx +++ b/apps/app/src/components/thread/terminal/ThreadTerminalView.tsx @@ -22,10 +22,8 @@ import type { import { useAppThemeEpoch } from "@/hooks/useAppTheme"; import { usePreferredTheme } from "@/hooks/useTheme"; import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link"; -import { - openUrlInExternalBrowser, - useOpenUrlByPreference, -} from "@/lib/url-open-routing"; +import { openUrlInExternalBrowser } from "@/lib/url-open-routing"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; import type { MessageProseSelection } from "@/components/thread/timeline/SelectableMessageProse.js"; import { TimelineSelectionMenu } from "@/components/thread/timeline/TimelineSelectionMenu.js"; import { buildTerminalWebSocketUrl } from "./terminal-websocket-url"; @@ -633,10 +631,10 @@ export function ThreadTerminalView({ // The xterm canvas bakes its palette, so re-apply the theme on app-palette // changes too, not just light/dark toggles. const appThemeEpoch = useAppThemeEpoch(); - const openUrlByPreference = useOpenUrlByPreference(); + const appNavigation = useAppNavigationHost(); const handleOpenLinkByPreference = useCallback( - ({ href }) => openUrlByPreference(href), - [openUrlByPreference], + ({ href }) => appNavigation.openUrl({ url: href }), + [appNavigation], ); const effectiveOnOpenLink = onOpenLink ?? handleOpenLinkByPreference; const onOpenLinkRef = useRef(effectiveOnOpenLink); diff --git a/apps/app/src/components/tools/PluginDetail.tsx b/apps/app/src/components/tools/PluginDetail.tsx index aa4f3e69a9..b2cd7fe377 100644 --- a/apps/app/src/components/tools/PluginDetail.tsx +++ b/apps/app/src/components/tools/PluginDetail.tsx @@ -34,6 +34,7 @@ import { PluginLogo, } from "@/components/plugin/management/plugin-ui"; import { pluginRuntimeStatusPresentation } from "@/components/plugin/management/plugin-status"; +import { ExperimentalUrlLink } from "@/components/plugin/ExperimentalUrlLink"; import { PluginHealthBanner, PluginIncludes, @@ -147,14 +148,12 @@ export function CatalogPluginDetail({ {entry.author.url === null ? ( entry.author.name ) : ( - {entry.author.name} - + )} )} diff --git a/apps/app/src/components/ui/markdown-preview.test.tsx b/apps/app/src/components/ui/markdown-preview.test.tsx index d39a984f3f..c71ecc690f 100644 --- a/apps/app/src/components/ui/markdown-preview.test.tsx +++ b/apps/app/src/components/ui/markdown-preview.test.tsx @@ -407,7 +407,7 @@ describe("MarkdownPreview", () => { expect(resolveSrc).toHaveBeenCalledTimes(2); }); - it("lets link routing open absolute app-origin URLs", () => { + it("keeps absolute app-origin URLs on the app-route path", () => { const onOpenLink = vi.fn(() => true); const href = `${window.location.origin}/threads/thr_localhost`; @@ -420,7 +420,10 @@ describe("MarkdownPreview", () => { fireEvent.click(screen.getByRole("link", { name: "local thread" })); - expect(onOpenLink).toHaveBeenCalledWith({ href }); + expect(onOpenLink).not.toHaveBeenCalled(); + expect( + screen.getByRole("link", { name: "local thread" }).getAttribute("href"), + ).toBe(href); }); it("rewrites localhost link hrefs without changing the visible text", () => { diff --git a/apps/app/src/components/ui/markdown-preview.tsx b/apps/app/src/components/ui/markdown-preview.tsx index 5b3e272439..2f0384b68b 100644 --- a/apps/app/src/components/ui/markdown-preview.tsx +++ b/apps/app/src/components/ui/markdown-preview.tsx @@ -671,9 +671,13 @@ function MarkdownAnchor({ return; } - // Let timeline/terminal hosts claim web links first. Absolute app-origin - // URLs can still be browser destinations even though they resolve to an - // app route. + // Internal BB destinations belong to RouteAnchor so they participate in + // SPA history. URL preference routing only sees non-route destinations. + if (isAppRouteHref) { + return; + } + + // Let timeline/terminal/navigation hosts claim ordinary web links. if ( linkRouting?.onOpenLink && rewrittenHref && @@ -682,10 +686,6 @@ function MarkdownAnchor({ event.preventDefault(); return; } - - if (isAppRouteHref) { - return; - } }; const anchor = ( diff --git a/apps/app/src/lib/app-navigation-host.tsx b/apps/app/src/lib/app-navigation-host.tsx new file mode 100644 index 0000000000..650bb2d444 --- /dev/null +++ b/apps/app/src/lib/app-navigation-host.tsx @@ -0,0 +1,57 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + type ReactNode, +} from "react"; + +export interface AppUrlOpenIntent { + url: string; +} + +export interface AppNavigationHostCapabilities { + openUrl?: (intent: AppUrlOpenIntent) => boolean; +} + +interface ResolvedAppNavigationHostCapabilities { + openUrl: ((intent: AppUrlOpenIntent) => boolean) | null; +} + +const AppNavigationHostContext = + createContext(null); + +/** + * Adds the navigation capabilities owned by one app surface. Providers compose: + * an omitted capability inherits the nearest outer host instead of disabling it. + */ +export function AppNavigationHostProvider({ + capabilities, + children, +}: { + capabilities: AppNavigationHostCapabilities; + children: ReactNode; +}) { + const parent = useContext(AppNavigationHostContext); + const value = useMemo( + () => ({ + openUrl: capabilities.openUrl ?? parent?.openUrl ?? null, + }), + [capabilities.openUrl, parent?.openUrl], + ); + return ( + + {children} + + ); +} + +/** Semantic navigation intents accepted by the current app surface. */ +export function useAppNavigationHost() { + const host = useContext(AppNavigationHostContext); + const openUrl = useCallback( + (intent: AppUrlOpenIntent): boolean => host?.openUrl?.(intent) ?? false, + [host?.openUrl], + ); + return useMemo(() => ({ openUrl }), [openUrl]); +} diff --git a/apps/app/src/lib/plugin-sdk-app-impl.test.tsx b/apps/app/src/lib/plugin-sdk-app-impl.test.tsx index 864048c309..0202f34322 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.test.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.test.tsx @@ -4,25 +4,29 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ThreadTimelineNavigationProvider } from "@/components/thread/timeline/ThreadTimelineNavigationContext"; import { pluginSdkAppImplementation } from "./plugin-sdk-app-impl"; +import { AppNavigationHostProvider } from "./app-navigation-host"; afterEach(cleanup); describe("plugin SDK Markdown", () => { it("uses the surrounding thread detail navigation for file and web links", () => { - const onOpenLink = vi.fn(() => true); + const onOpenLink = vi.fn(() => false); + const openUrl = vi.fn(() => true); const onOpenLocalFileLink = vi.fn(() => true); const Markdown = pluginSdkAppImplementation.Markdown; render( - null} - workspaceRootPath="/workspace" - > - - , + + null} + workspaceRootPath="/workspace" + > + + + , ); const fileLink = screen.getByRole("link", { name: "README" }); @@ -34,8 +38,21 @@ describe("plugin SDK Markdown", () => { }); fireEvent.click(screen.getByRole("link", { name: "the docs" })); - expect(onOpenLink).toHaveBeenCalledWith({ - href: "https://example.com/docs", + expect(openUrl).toHaveBeenCalledWith({ + url: "https://example.com/docs", }); + expect(onOpenLink).not.toHaveBeenCalled(); + }); + + it("routes web links without requiring a thread navigation context", () => { + const openUrl = vi.fn(() => true); + const Markdown = pluginSdkAppImplementation.Markdown; + render( + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "Docs" })); + expect(openUrl).toHaveBeenCalledWith({ url: "https://example.com/docs" }); }); }); diff --git a/apps/app/src/lib/plugin-sdk-app-impl.tsx b/apps/app/src/lib/plugin-sdk-app-impl.tsx index c754eff3a5..9f877c2d33 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.tsx @@ -1,14 +1,16 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import type { MarkdownProps, PluginSdkApp } from "@get-bb/plugin-sdk"; import { PluginDiff } from "@/components/plugin/PluginDiff"; import { PluginNewThreadComposer } from "@/components/plugin/PluginNewThreadComposer"; import { PluginSourceCode } from "@/components/plugin/PluginSourceCode"; import { PluginThreadChat } from "@/components/plugin/PluginThreadChat"; +import { ExperimentalUrlLink } from "@/components/plugin/ExperimentalUrlLink"; import { MarkdownPreview } from "@/components/ui/markdown-preview"; import type { MarkdownLinkRouting, MarkdownLocalFileLinkRouting, } from "@/components/ui/markdown-link-routing"; +import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link"; import { useThreadTimelineNavigation } from "@/components/thread/timeline/ThreadTimelineNavigationContext"; import { definePluginApp } from "./plugin-app-definition"; import { @@ -27,6 +29,7 @@ import { useSidebarThreads, } from "./plugin-sidebar-hooks"; import { useSidebarThreadSplit } from "./plugin-sidebar-split"; +import { useAppNavigationHost } from "./app-navigation-host"; /** * The real `@get-bb/plugin-sdk/app` surface (plugin design §5.2), assigned to @@ -57,6 +60,7 @@ export const pluginSdkAppImplementation = { // exception to §5.5) — stable product capabilities, not a UI kit. ThreadChat: PluginThreadChat, Markdown: PluginMarkdown, + experimental_UrlLink: ExperimentalUrlLink, // Experimental (see docs/api_to_audit.md): the create-side counterpart to // ThreadChat. experimental_NewThreadComposer: PluginNewThreadComposer, @@ -80,12 +84,16 @@ export const pluginSdkAppImplementation = { */ function PluginMarkdown({ content, className }: MarkdownProps) { const timelineNavigation = useThreadTimelineNavigation(); - const onOpenLink = timelineNavigation?.onOpenLink; const onOpenLocalFileLink = timelineNavigation?.onOpenLocalFileLink; const workspaceRootPath = timelineNavigation?.workspaceRootPath; - const linkRouting = useMemo(() => { - if (onOpenLink === undefined || onOpenLocalFileLink === undefined) { - return undefined; + const navigation = useAppNavigationHost(); + const onOpenLink = useCallback( + ({ href }) => navigation.openUrl({ url: href }), + [navigation], + ); + const linkRouting = useMemo(() => { + if (onOpenLocalFileLink === undefined) { + return { onOpenLink }; } const localFile: MarkdownLocalFileLinkRouting = { absoluteLinks: { kind: "trusted-host" }, diff --git a/apps/app/src/lib/plugin-sdk-hooks.ts b/apps/app/src/lib/plugin-sdk-hooks.ts index 3f483f42e7..23fa6dc324 100644 --- a/apps/app/src/lib/plugin-sdk-hooks.ts +++ b/apps/app/src/lib/plugin-sdk-hooks.ts @@ -52,6 +52,7 @@ import { useRouteState } from "@/hooks/useRouteState"; import { useServerConnectionState } from "@/hooks/useServerConnectionState"; import { wsManager } from "@/lib/ws"; import { pluginSdkSettingsQueryKey } from "@/hooks/queries/query-keys"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; /** * Host implementations of the `@get-bb/plugin-sdk/app` hooks (plugin design @@ -277,6 +278,7 @@ export function useBbNavigate(): BbNavigate { const location = useLocation(); const openThreadPanelHandler = usePluginThreadPanelOpenHandler(); const navigate = useNavigate(); + const appNavigation = useAppNavigationHost(); const toThread = useCallback( (threadId: string) => { // The canonical thread path carries the owning project, which the @@ -334,6 +336,10 @@ export function useBbNavigate(): BbNavigate { (options) => openThreadPanelHandler?.({ ...options, pluginId }) ?? false, [openThreadPanelHandler, pluginId], ); + const experimental_openUrl = useCallback( + (url) => appNavigation.openUrl({ url }), + [appNavigation], + ); return useMemo( () => ({ toThread, @@ -341,8 +347,16 @@ export function useBbNavigate(): BbNavigate { toPluginPanel, toCompose, openThreadPanel, + experimental_openUrl, }), - [toThread, toProject, toPluginPanel, toCompose, openThreadPanel], + [ + toThread, + toProject, + toPluginPanel, + toCompose, + openThreadPanel, + experimental_openUrl, + ], ); } diff --git a/apps/app/src/lib/url-open-routing.tsx b/apps/app/src/lib/url-open-routing.tsx index 7726d0af51..f80863d111 100644 --- a/apps/app/src/lib/url-open-routing.tsx +++ b/apps/app/src/lib/url-open-routing.tsx @@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, + useMemo, type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; @@ -10,6 +11,7 @@ import { openUrlByPreference, useOpenLinksInAppBrowserPreference, } from "@/lib/in-app-browser-link-preference"; +import { AppNavigationHostProvider } from "@/lib/app-navigation-host"; export type OpenInAppBrowserUrl = (url: string) => void; @@ -22,8 +24,9 @@ type UrlAnchorClickHandler = ( event: ReactMouseEvent, ) => void; -const InAppBrowserUrlOpenContext = - createContext(null); +const InAppBrowserUrlOpenContext = createContext( + null, +); export function openUrlInExternalBrowser(url: string): void { const desktopInfo = getBbDesktopInfo(); @@ -42,11 +45,25 @@ export function UrlOpenRoutingProvider({ }: UrlOpenRoutingProviderProps) { return ( - {children} + {children} ); } +/** Installs URL opening for a window or a nested browser-capable surface. */ +export function AppNavigationUrlHost({ children }: { children: ReactNode }) { + const openUrl = useOpenUrlByPreference(); + const capabilities = useMemo( + () => ({ openUrl: ({ url }: { url: string }) => openUrl(url) }), + [openUrl], + ); + return ( + + {children} + + ); +} + export function useOpenUrlByPreference(): (url: string) => boolean { const openInAppBrowser = useContext(InAppBrowserUrlOpenContext); const [openLinksInAppBrowser] = useOpenLinksInAppBrowserPreference(); @@ -76,6 +93,9 @@ export function useUrlAnchorClickHandler( if (event.defaultPrevented || event.button !== 0 || url === undefined) { return; } + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } if (openUrl(url)) { event.preventDefault(); } diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 30f99544f8..1bf418a99a 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -136,6 +136,7 @@ import { useOpenLinksInAppBrowserPreference, } from "@/lib/in-app-browser-link-preference"; import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link"; +import { UrlOpenRoutingProvider } from "@/lib/url-open-routing"; import { useRootComposeProjectId, useSetRootComposeProjectId, @@ -2349,76 +2350,83 @@ function RootComposeSurface({ {machineSetupDialog} {rootPanelToggle} - ` actionId never matches a panel action. - (activePluginPanelTab.fileOpenerOwner !== undefined || - rootPanelNewThreadPanelActions.find( - (candidate) => - candidate.pluginId === activePluginPanelTab.pluginId && - candidate.id === activePluginPanelTab.actionId, - )?.layout === "flush"), - renderBrowserDeck, - isBrowserTabActive, - isOpen: isSecondaryPanelOpen, - fixedTabs: [], - // The shell, tab strip, launcher, resize, and drawer behavior are - // shared with threads. Info, Diff, and conversation full-screen - // stay thread-only because no thread exists on this surface yet. - showConversationCollapseControl: false, - inlinePanelToggle: panelTogglePlacement.inlinePanelToggle, - onClose: closeSecondaryPanel, - onCollapse: closeSecondaryPanel, - onOpenFileInEditor: handleOpenWorkspaceFileInEditor, - onFileTabReorder: reorderFileTab, - onOpenNewTab: handleOpenNewTab, - onOpenFilePreview: handleOpenFilePreview, - onSelectionAddToChat: handleRootPanelSelectionAddToChat, - onPanelFocus: handleSecondaryPanelFocus, - }} > - {showEmptyWelcome ? ( - - ) : ( - <> - {promptBox} - + candidate.pluginId === activePluginPanelTab.pluginId && + candidate.id === activePluginPanelTab.actionId, + )?.layout === "flush"), + renderBrowserDeck, + isBrowserTabActive, + isOpen: isSecondaryPanelOpen, + fixedTabs: [], + // The shell, tab strip, launcher, resize, and drawer behavior are + // shared with threads. Info, Diff, and conversation full-screen + // stay thread-only because no thread exists on this surface yet. + showConversationCollapseControl: false, + inlinePanelToggle: panelTogglePlacement.inlinePanelToggle, + onClose: closeSecondaryPanel, + onCollapse: closeSecondaryPanel, + onOpenFileInEditor: handleOpenWorkspaceFileInEditor, + onFileTabReorder: reorderFileTab, + onOpenNewTab: handleOpenNewTab, + onOpenFilePreview: handleOpenFilePreview, + onSelectionAddToChat: handleRootPanelSelectionAddToChat, + onPanelFocus: handleSecondaryPanelFocus, + }} + > + {showEmptyWelcome ? ( + - - )} - + ) : ( + <> + {promptBox} + + + )} + + ); diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 1868a57bfb..d8e34326c5 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -1257,6 +1257,7 @@ import { useSettings, useBbContext, useBbNavigate, + experimental_UrlLink as UrlLink, useComposer, useComposerView, } from "@get-bb/plugin-sdk/app"; @@ -1863,6 +1864,13 @@ className?, leadingContent?, messageActions? }` — message content (e.g. a reply header) so it reads like the rest of the chat instead of a differently-styled bundled renderer. Renderer options beyond content/className stay host-internal. +- `experimental_UrlLink` — a real anchor whose ordinary HTTP(S) activation + follows the current client's in-app/external-browser preference. It keeps + internal BB routes in SPA history, preserves modifier clicks, copying, + accessibility, and explicit anchor props, and leaves unsupported schemes to + normal browser behavior. Use `useBbNavigate().experimental_openUrl(url)` for + buttons, menus, and effects; its boolean reports whether the current app + accepted the intent, not whether a later OS launch completed. - `experimental_NewThreadComposer` — bb's complete compose surface for CREATING a thread (the create-side counterpart to `ThreadChat`): prompt editor with @-mentions and expand, `+` attachments, @@ -1962,12 +1970,14 @@ Hooks: - `useBbContext()` → `{ projectId, threadId }` from the current route. - `useBbNavigate()` → `{ toThread(id), toProject(id), toPluginPanel(path, { subPath?, replace? }?), toCompose({ initialPrompt?, focusPrompt? }?), -openThreadPanel({ actionId, title?, params? }) }`. +openThreadPanel({ actionId, title?, params? }), experimental_openUrl(url) }`. `toCompose` opens the root compose screen; pass `initialPrompt` to seed the composer draft and `focusPrompt: true` to focus it. The panel opener opens one of the current plugin's registered `threadPanelAction` tabs in the current thread surface and returns whether the host accepted it; it returns false on surfaces without a thread side panel. + `experimental_openUrl` owns HTTP(S) only and returns false for schemes BB + leaves to normal anchor behavior. - `useComposer()` → programmatic access to the chat composer draft (the same one the built-in "Add to chat" affordances write to): `text` is the current plain text; `setText(next)` replaces it; @@ -2224,6 +2234,7 @@ const slot = renderSlot( settings: { greeting: "hi" }, // useSettings() values context: { projectId: "p1", threadId: null }, // useBbContext() realtimeConnectionState: "reconnecting", // useRealtimeConnectionState() + openUrl: (url) => url.startsWith("https://"), }, ); await slot.findByText("…"); // Testing Library queries diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index e39c41b2ee..580997e649 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -49,6 +49,30 @@ enum, and that ACP's shared bridge can continue distinguishing built-in and custom agents without exposing provider-specific launch or installation details to clients. +## URL navigation (`experimental_UrlLink` and `BbNavigate.experimental_openUrl`) + +**What it does.** Gives plugin UI the same semantic HTTP(S) opening path as +first-party UI. Ordinary activation respects the current client's in-app +browser preference and capability; app routes remain SPA navigation, modifier +clicks and explicit anchor targets remain native, and unsupported schemes are +left to the browser. The imperative method returns whether the current app +accepted the intent. The frontend harness records link and imperative calls +through the same navigation inspection log. + +**Audit before stabilizing.** + +1. Confirm HTTP(S)-only ownership and external fallback across desktop, web, + remote clients, and windows whose current surface cannot host Browser. +2. Audit internal absolute and relative routes, fragments, modifier clicks, + keyboard activation, explicit targets, copied hrefs, and accessible names. +3. Confirm the component should retain ordinary anchor props rather than a + smaller styled-link contract, and that explicit `target` continues to mean + native browser behavior rather than BB preference routing. +4. Measure use across plugin pages, Settings sections, panel tabs, Markdown, + and menus before stabilizing the boolean acceptance contract. +5. Keep the host implementation in the shell and verify plugin bundles contain + only the runtime indirection, not BB browser or panel code. + ## Host plugin foundation (`bb.hosts.experimental_client`, `ExperimentalHostClient.experimental_onWorkerExit`, `ExperimentalHostClient.experimental_onSignal`, `ExperimentalHostRpcContext.experimental_retainWorker`, `experimental_defineHostEntry`, and `experimental_createHostEntryHarness`) **What it does.** Lets one plugin package declare a singular `bb.host` Node diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 0036d7d370..70cc4501c2 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -21,6 +21,13 @@ Any mounted plugin component can use same plugin's registered thread-panel actions; it returns false when the current surface has no thread side panel. +Use `experimental_UrlLink` for a real anchor that applies BB's current +in-app/external-browser preference on ordinary HTTP(S) activation, or +`useBbNavigate().experimental_openUrl(url)` for a button or menu. Internal app +routes, modifier clicks, explicit anchor targets, and unsupported schemes stay +native. The frontend harness records both forms in `navigateCalls` and accepts +an `openUrl` behavior option. + Every panel-open entry point reports the same way: `openThreadPanel` and the `openPanel` handed to `threadPanelAction`, `experimental_newThreadPanelAction`, and `messageAction` `run` callbacks all return `boolean` — true when the host diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index 83da9aaec5..17e8ec2503 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1,4 +1,4 @@ -import type { ComponentType, ReactNode } from "react"; +import type { ComponentPropsWithoutRef, ComponentType, ReactNode } from "react"; import type { PermissionMode, PromptInput, @@ -1559,6 +1559,18 @@ export interface MarkdownProps { className?: string; } +/** + * Props for BB's semantic URL link. The host owns ordinary activation while + * retaining native anchor behavior for app routes, modifiers, copying, and + * unsupported schemes. Experimental: see docs/api_to_audit.md. + */ +export interface ExperimentalUrlLinkProps extends Omit< + ComponentPropsWithoutRef<"a">, + "href" +> { + href: string; +} + /** Current app selection, derived from the route. */ export interface BbContext { projectId: string | null; @@ -1591,6 +1603,12 @@ export interface BbNavigate { * the action is unavailable. */ openThreadPanel(options: PluginTargetedPanelActionOpenOptions): boolean; + /** + * Open an HTTP(S) URL using this client's BB browser preference. Returns + * false for schemes the host does not own. Experimental: see + * docs/api_to_audit.md. + */ + experimental_openUrl(url: string): boolean; } // --------------------------------------------------------------------------- @@ -1683,6 +1701,11 @@ export interface PluginSdkApp { * {@link MarkdownProps}). */ Markdown: ComponentType; + /** + * A real anchor whose ordinary HTTP(S) activation uses BB's URL preference. + * Experimental: see docs/api_to_audit.md. + */ + experimental_UrlLink: ComponentType; /** * The host-owned new-thread compose surface (see * {@link NewThreadComposerProps}). Experimental: see diff --git a/packages/plugin-sdk/src/app.ts b/packages/plugin-sdk/src/app.ts index f07ebf75f4..21e3e9021d 100644 --- a/packages/plugin-sdk/src/app.ts +++ b/packages/plugin-sdk/src/app.ts @@ -48,6 +48,7 @@ const runtime = ((globalThis as PluginRuntimeHost).__bbPluginRuntime export const definePluginApp = runtime.definePluginApp; export const ThreadChat = runtime.ThreadChat; export const Markdown = runtime.Markdown; +export const experimental_UrlLink = runtime.experimental_UrlLink; export const experimental_NewThreadComposer = runtime.experimental_NewThreadComposer; // Host-owned code rendering (experimental — see docs/api_to_audit.md). diff --git a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx index 04eafb2d1b..db804126f3 100644 --- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx +++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx @@ -22,7 +22,9 @@ import { defineRpcContract } from "../../rpc-contract.js"; installTestPluginRuntime(); const { definePluginApp, + experimental_UrlLink: UrlLink, ThreadChat, + useBbNavigate, useComposer, useComposerView, useRealtime, @@ -84,6 +86,23 @@ function RealtimeConnectionProbe() { return
Realtime: {state}
; } +function UrlNavigationProbe() { + const navigate = useBbNavigate(); + return ( +
+ Open link + +
+ ); +} + let capturedComposerVisualSetters: Pick< PluginComposerApi, "setTextEffect" | "setInputLock" @@ -939,6 +958,23 @@ describe("typed rpc test runtime", () => { }); describe("renderSlot", () => { + it("records URL intents from links and imperative navigation through one host boundary", () => { + const slot = renderSlot( + { component: UrlNavigationProbe }, + {}, + { openUrl: () => true }, + ); + fireEvent.click(slot.getByRole("link", { name: "Open link" })); + fireEvent.click(slot.getByRole("button", { name: "Open imperatively" })); + expect(slot.inspection.navigateCalls).toEqual([ + { method: "experimental_openUrl", url: "https://example.com/from-link" }, + { + method: "experimental_openUrl", + url: "https://example.com/imperative", + }, + ]); + }); + it("drives the shared realtime connection lifecycle", async () => { const slot = renderSlot( app.homepageSections[0]!, diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index 7f8a092eb6..6341f960b8 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -8,6 +8,7 @@ import { useSyncExternalStore, type ComponentType, type ReactElement, + type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; import { act, render, type RenderResult } from "@testing-library/react"; @@ -53,6 +54,7 @@ import { type PluginRpcResult, type StandardSchemaV1InferInput, type MarkdownProps, + type ExperimentalUrlLinkProps, type NewThreadComposerProps, type ThreadChatProps, type DiffProps, @@ -111,7 +113,8 @@ export type NavigateCall = | { method: "openThreadPanel"; options: Parameters[0]; - }; + } + | { method: "experimental_openUrl"; url: string }; export interface ComposerLog { /** Latest plain text in this isolated composer scope. */ @@ -289,6 +292,40 @@ function TestMarkdown({ content, className }: MarkdownProps) { ); } +/** Anchor-faithful stand-in backed by the same navigation recorder as the hook. */ +function TestUrlLink({ + href, + onClick, + rel, + target, + ...anchorProps +}: ExperimentalUrlLinkProps) { + const navigate = useSlotEnv("experimental_UrlLink").navigate; + return ( + ) => { + onClick?.(event); + if ( + event.defaultPrevented || + event.button !== 0 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.currentTarget.hasAttribute("download") + ) { + return; + } + if (navigate.experimental_openUrl(href)) event.preventDefault(); + }} + /> + ); +} + /** * Stand-in for the host-owned new-thread composer: a textarea plus a submit * button that calls `onSubmit` with a fixed, obviously-synthetic request, so @@ -483,6 +520,7 @@ const testPluginSdkApp = { }, ThreadChat: TestThreadChat, Markdown: TestMarkdown, + experimental_UrlLink: TestUrlLink, experimental_NewThreadComposer: TestNewThreadComposer, experimental_SourceCode: TestSourceCode, experimental_Diff: TestDiff, @@ -798,6 +836,8 @@ export interface RenderSlotOptions< openThreadPanel?: ( options: Parameters[0], ) => boolean; + /** Host acceptance for URL intents from the hook or `experimental_UrlLink`. */ + openUrl?: (url: string) => boolean; } /** Host-originated inputs a slot test can drive deterministically. */ @@ -1010,6 +1050,10 @@ export function renderSlot< }); return options.openThreadPanel?.(panelOptions) ?? false; }, + experimental_openUrl(url) { + navigateCalls.push({ method: "experimental_openUrl", url }); + return options.openUrl?.(url) ?? false; + }, }; const projectId = options.context?.projectId ?? null; diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 9c6c333e6e..66eb53ace9 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -519,7 +519,9 @@ useRpc, useRealtime, useRealtimeConnectionState (the shared realtime socket's connecting/connected/reconnecting lifecycle; reconcile on later connected transitions, not the initial connection), useSettings (secrets excluded), useBbContext, -useBbNavigate, useComposer (read/replace/update/clear scoped composer text, +useBbNavigate (including experimental_openUrl(url), which applies the current +client's in-app/external-browser preference), useComposer +(read/replace/update/clear scoped composer text, apply a class-based text effect, lock input, quote selections, insert mention pills, and focus the composer), and useComposerView (reactive bound scope, layout, draft, and run state). Plain-text edits preserve attachments and @@ -533,7 +535,11 @@ error codes. Components are vendored shadcn source the plugin owns (the shadcn model): `bb plugin new --app` pre-vendors a starter set into components/ui/ and `npx shadcn add @bb/` pulls more from the BB component registry (the full stock shadcn set, version-matched to the -running BB via the pinned ref in components.json). `import { toast } from +running BB via the pinned ref in components.json). Product capabilities are +the exception: experimental_UrlLink renders a real anchor whose ordinary +HTTP(S) activation uses the same client preference as first-party links while +leaving app routes, modifiers, copying, and unsupported schemes native. +`import { toast } from "sonner"` reaches the host toaster; react, the portaling radix families, sonner, vaul, @pierre/diffs, and the host-resident clsx, tailwind-merge, and class-variance-authority libraries are runtime-shimmed (never bundled) — diff --git a/plugins/github/app.tsx b/plugins/github/app.tsx index 5fdc83e1ac..3baaade8fa 100644 --- a/plugins/github/app.tsx +++ b/plugins/github/app.tsx @@ -19,6 +19,7 @@ import { import { definePluginApp, experimental_Diff as Diff, + experimental_UrlLink as UrlLink, useBbNavigate, useRealtime, useRpc, @@ -659,6 +660,7 @@ function StatusCell({ item }: { item: Item }) { } function RowMenu({ item }: { item: Item }) { + const navigate = useBbNavigate(); const viewer = useViewer(); const { setIssueState, setAssignees } = useIssueMutations(); const assignedToMe = viewer !== null && item.assignees.includes(viewer); @@ -703,7 +705,11 @@ function RowMenu({ item }: { item: Item }) { ) : null} {item.kind === "issue" ? : null} - window.open(item.url, "_blank")}> + { + navigate.experimental_openUrl(item.url); + }} + > Open on GitHub ↗ - + Open on GitHub ↗ - +
@@ -1369,14 +1370,12 @@ function ChecksSection({ checks }: { checks: PullCheck[] }) { {check.name} {check.url.length > 0 ? ( - details ↗ - + ) : null}
))} @@ -1418,9 +1417,9 @@ function FileDiffCard({ file, url }: { file: PullFile; url: string }) { ) : (

Diff too large to inline —{" "} - + view on GitHub ↗ - +

) ) : null} @@ -1665,14 +1664,12 @@ function PullDetailView({ {repo} · #{number} - Open on GitHub ↗ - +
From 4e7cce81ea2227e201e99d18b6b54873f4fe7530 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 18 Aug 2026 22:56:11 -0700 Subject: [PATCH 02/18] Add shared plugin file navigation --- apps/app/src/App.tsx | 23 +- .../AppFileExternalNavigationDispatcher.tsx | 58 +++ .../AppFileExternalNavigationHost.test.tsx | 75 +++ .../plugin/AppFileExternalNavigationHost.tsx | 68 +++ .../plugin/ExperimentalFileLink.test.tsx | 73 +++ .../plugin/ExperimentalFileLink.tsx | 83 ++++ .../plugin/ExperimentalFileLinkMenu.tsx | 148 ++++++ .../plugin/PluginPanelRightPanelHost.test.tsx | 112 +++++ .../plugin/PluginPanelRightPanelHost.tsx | 333 ++++++++++---- .../src/components/plugin/file-opener-tabs.ts | 32 +- .../plugin/plugin-slot-mounts.test.tsx | 1 + .../ThreadSecondaryPanel.stories.tsx | 1 + .../ThreadSecondaryPanelTabContent.tsx | 38 ++ .../lazySecondaryPanelComponents.tsx | 19 + .../secondary-panel/useThreadFileTabs.test.ts | 1 + .../secondary-panel/useThreadFileTabs.ts | 53 ++- .../hooks/queries/host-file-preview-query.ts | 78 ++++ apps/app/src/hooks/queries/query-keys.ts | 13 + apps/app/src/hooks/useLocalOpenTargets.ts | 3 + .../src/hooks/useResolvedLiveFileTarget.ts | 128 ++++++ apps/app/src/hooks/useWorkspaceOpenTargets.ts | 18 +- apps/app/src/lib/app-navigation-host.tsx | 39 +- .../src/lib/fixed-panel-tabs-state.test.ts | 19 + apps/app/src/lib/live-file-navigation.test.ts | 80 ++++ apps/app/src/lib/live-file-navigation.ts | 211 +++++++++ apps/app/src/lib/plugin-sdk-app-impl.test.tsx | 29 ++ apps/app/src/lib/plugin-sdk-app-impl.tsx | 2 + apps/app/src/lib/plugin-sdk-hooks.ts | 25 + apps/app/src/lib/use-async-atom-value.ts | 22 +- apps/app/src/views/RootComposeView.tsx | 249 ++++++---- .../views/thread-detail/ThreadDetailView.tsx | 426 ++++++++++-------- .../bb-plugin-authoring/SKILL.md | 15 + .../test/public/public-thread-tabs.test.ts | 1 + docs/api_to_audit.md | 31 ++ .../src/panel/fixed-panel-tabs-state.ts | 24 +- packages/plugin-sdk/README.md | 10 + packages/plugin-sdk/src/app-contract.ts | 34 ++ packages/plugin-sdk/src/app.ts | 1 + .../testing/__tests__/app-harness.test.tsx | 42 ++ packages/plugin-sdk/src/testing/app.tsx | 64 ++- .../server-contract/src/api/thread-tabs.ts | 14 +- .../server-contract/test/thread-tabs.test.ts | 17 +- .../src/templates/bb-guide-plugins.md | 9 +- plugins/docs/app.test.tsx | 1 + plugins/docs/app.tsx | 49 ++ plugins/github/app.tsx | 87 +++- plugins/github/server.ts | 31 +- 47 files changed, 2484 insertions(+), 406 deletions(-) create mode 100644 apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx create mode 100644 apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx create mode 100644 apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx create mode 100644 apps/app/src/components/plugin/ExperimentalFileLink.test.tsx create mode 100644 apps/app/src/components/plugin/ExperimentalFileLink.tsx create mode 100644 apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx create mode 100644 apps/app/src/hooks/queries/host-file-preview-query.ts create mode 100644 apps/app/src/hooks/useResolvedLiveFileTarget.ts create mode 100644 apps/app/src/lib/live-file-navigation.test.ts create mode 100644 apps/app/src/lib/live-file-navigation.ts diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 66c7d6698a..1691ea970e 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -11,6 +11,7 @@ import { AuthCallbackView } from "./views/AuthCallbackView"; import { QuickCreateProjectProvider } from "./hooks/useQuickCreateProject"; import { RouteNavigationProvider } from "./components/ui/app-route-anchor"; import { AppNavigationUrlHost } from "./lib/url-open-routing"; +import { AppFileExternalNavigationHost } from "./components/plugin/AppFileExternalNavigationHost"; import { useAppTheme } from "./hooks/useAppTheme"; import { useFaviconColorSync } from "./lib/favicon-color-preference"; import { useDesktopThemeSync } from "./hooks/useDesktopThemeSync"; @@ -386,18 +387,20 @@ export function App() { - - - } - /> - } /> - - {/* Outside : a provider CLI install outlives the page that + + + + } + /> + } /> + + {/* Outside : a provider CLI install outlives the page that started it, so its failure toast can be clicked from any route — including auth callback, which renders no app shell. */} - + + diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx new file mode 100644 index 0000000000..0d7a1c86dd --- /dev/null +++ b/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef } from "react"; +import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import { appToast } from "@/components/ui/app-toast"; +import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; +import { useResolvedLiveFileTarget } from "@/hooks/useResolvedLiveFileTarget"; +import { getExperimentalFileLocationStart } from "@/lib/live-file-navigation"; + +/** Lazily loaded only after an external-file intent has been accepted. */ +export function AppFileExternalNavigationDispatcher({ + intent, + onSettled, +}: { + intent: ExperimentalFileOpenOptions; + onSettled: () => void; +}) { + const didSettleRef = useRef(false); + const resolvedTarget = useResolvedLiveFileTarget(intent.target, { + enabled: true, + }); + const localTargets = useLocalOpenTargets({ + enabled: resolvedTarget.status === "available", + ...(resolvedTarget.status === "available" + ? { openContext: resolvedTarget.openContext } + : {}), + }); + + useEffect(() => { + if ( + didSettleRef.current || + resolvedTarget.status === "loading" || + localTargets.isLoading + ) { + return; + } + didSettleRef.current = true; + onSettled(); + if (resolvedTarget.status === "unavailable") { + appToast.error("Failed to open file externally", { + description: "The file target is not available on its declared host.", + }); + return; + } + const location = getExperimentalFileLocationStart(intent.location); + void localTargets.openPathInPreferredFileTarget({ + columnNumber: location.columnNumber, + lineNumber: location.lineNumber, + path: resolvedTarget.absolutePath, + }); + }, [ + intent.location, + localTargets.isLoading, + localTargets.openPathInPreferredFileTarget, + onSettled, + resolvedTarget, + ]); + + return null; +} diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx new file mode 100644 index 0000000000..41fb3297a2 --- /dev/null +++ b/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { AppFileExternalNavigationHost } from "./AppFileExternalNavigationHost"; + +const openPreferred = vi.hoisted(() => vi.fn()); + +vi.mock("@/hooks/useResolvedLiveFileTarget", () => ({ + useResolvedLiveFileTarget: () => ({ + status: "available", + absolutePath: "/workspace/src/example.ts", + hostId: "host_1", + openContext: { kind: "local" }, + }), +})); + +vi.mock("@/hooks/useLocalOpenTargets", () => ({ + useLocalOpenTargets: () => ({ + isLoading: false, + openPathInPreferredFileTarget: openPreferred, + }), +})); + +function Probe() { + const navigation = useAppNavigationHost(); + return ( + + ); +} + +afterEach(() => { + cleanup(); + openPreferred.mockReset(); + openPreferred.mockResolvedValue(true); +}); + +describe("AppFileExternalNavigationHost", () => { + it("resolves and dispatches an accepted intent through the preferred target", async () => { + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Open external" })); + await waitFor(() => + expect(openPreferred).toHaveBeenCalledWith({ + columnNumber: 3, + lineNumber: 12, + path: "/workspace/src/example.ts", + }), + ); + }); +}); diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx new file mode 100644 index 0000000000..bbed6792b9 --- /dev/null +++ b/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx @@ -0,0 +1,68 @@ +import { + lazy, + Suspense, + useCallback, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import { AppNavigationHostProvider } from "@/lib/app-navigation-host"; + +const MAX_PENDING_EXTERNAL_FILE_INTENTS = 32; +const LazyAppFileExternalNavigationDispatcher = lazy(() => + import("./AppFileExternalNavigationDispatcher").then( + ({ AppFileExternalNavigationDispatcher }) => ({ + default: AppFileExternalNavigationDispatcher, + }), + ), +); + +/** App-wide preferred-external file dispatcher; discovery starts on activation. */ +export function AppFileExternalNavigationHost({ + children, +}: { + children: ReactNode; +}) { + const [queue, setQueue] = useState([]); + const queueRef = useRef(queue); + const replaceQueue = useCallback((next: ExperimentalFileOpenOptions[]) => { + queueRef.current = next; + setQueue(next); + }, []); + const openFileExternally = useCallback( + (intent: ExperimentalFileOpenOptions): boolean => { + if (queueRef.current.length >= MAX_PENDING_EXTERNAL_FILE_INTENTS) { + return false; + } + // Public SDK callers are parsed by useBbNavigate before capabilities are + // invoked; this host only queues that already-normalized internal value. + replaceQueue([...queueRef.current, intent]); + return true; + }, + [replaceQueue], + ); + const current = queue[0] ?? null; + const settleCurrent = useCallback(() => { + replaceQueue(queueRef.current.slice(1)); + }, [replaceQueue]); + + const capabilities = useMemo( + () => ({ openFileExternally }), + [openFileExternally], + ); + return ( + + {children} + {current === null ? null : ( + + + + )} + + ); +} diff --git a/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx b/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx new file mode 100644 index 0000000000..0bb7c31790 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { RouteNavigationProvider } from "@/components/ui/app-route-anchor"; +import { AppNavigationHostProvider } from "@/lib/app-navigation-host"; +import { ExperimentalFileLink } from "./ExperimentalFileLink"; + +afterEach(cleanup); + +const target = { + kind: "workspace" as const, + environmentId: "env_1", + path: "src/example.ts", +}; + +describe("ExperimentalFileLink", () => { + it("sends ordinary activation to the shared preview host", () => { + const openFilePreview = vi.fn(() => true); + render( + + + + + example.ts:12 + + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "example.ts:12" })); + expect(openFilePreview).toHaveBeenCalledWith({ + target, + location: { kind: "line", line: 12, column: 4 }, + }); + }); + + it("leaves modifier clicks native", () => { + const openFilePreview = vi.fn(() => true); + render( + + + + example.ts + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "example.ts" }), { + metaKey: true, + }); + expect(openFilePreview).not.toHaveBeenCalled(); + }); + + it("does not dispatch a malformed target supplied across a JavaScript boundary", () => { + const openFilePreview = vi.fn(() => true); + render( + + + + invalid + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "invalid" })); + expect(openFilePreview).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/plugin/ExperimentalFileLink.tsx b/apps/app/src/components/plugin/ExperimentalFileLink.tsx new file mode 100644 index 0000000000..24be2d40d0 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalFileLink.tsx @@ -0,0 +1,83 @@ +import { + lazy, + Suspense, + useCallback, + useMemo, + useState, + type MouseEvent as ReactMouseEvent, +} from "react"; +import type { ExperimentalFileLinkProps } from "@get-bb/plugin-sdk"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@bb/shared-ui/context-menu"; +import { RouteAnchor } from "@/components/ui/app-route-anchor"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { normalizeExperimentalFileOpenOptions } from "@/lib/live-file-navigation"; + +const LazyExperimentalFileLinkMenu = lazy(() => + import("./ExperimentalFileLinkMenu").then(({ ExperimentalFileLinkMenu }) => ({ + default: ExperimentalFileLinkMenu, + })), +); + +function shouldHandleFileClick( + event: ReactMouseEvent, +): boolean { + return !( + event.defaultPrevented || + event.button !== 0 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.currentTarget.hasAttribute("download") + ); +} + +/** Host-rendered live-file anchor shared by plugins and BB-owned surfaces. */ +export function ExperimentalFileLink({ + target, + location = null, + onClick, + ...anchorProps +}: ExperimentalFileLinkProps) { + const navigation = useAppNavigationHost(); + const [isMenuOpen, setMenuOpen] = useState(false); + const intent = useMemo( + () => normalizeExperimentalFileOpenOptions({ target, location }), + [location, target], + ); + const handleClick = useCallback( + (event: ReactMouseEvent) => { + onClick?.(event); + if (intent === null || !shouldHandleFileClick(event)) { + return; + } + event.preventDefault(); + navigation.openFilePreview(intent); + }, + [intent, navigation, onClick], + ); + const anchor = ( + + ); + + if (intent === null) return anchor; + return ( + + {anchor} + + {isMenuOpen ? ( + Loading…} + > + + + ) : null} + + + ); +} diff --git a/apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx b/apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx new file mode 100644 index 0000000000..8a2bb66f45 --- /dev/null +++ b/apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx @@ -0,0 +1,148 @@ +import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +} from "@bb/shared-ui/context-menu"; +import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; +import { useResolvedLiveFileTarget } from "@/hooks/useResolvedLiveFileTarget"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { copyToClipboardWithToast } from "@/lib/clipboard"; +import { getExperimentalFileLocationStart } from "@/lib/live-file-navigation"; +import { usePluginSlots } from "@/lib/plugin-slots"; + +function getFileBasename(path: string): string { + const normalizedPath = path.replace(/[\\/]+$/u, ""); + return normalizedPath.split(/[\\/]/u).at(-1) ?? path; +} + +function getFileExtension(path: string): string | null { + const name = getFileBasename(path); + const dotIndex = name.lastIndexOf("."); + return dotIndex > 0 && dotIndex < name.length - 1 + ? name.slice(dotIndex + 1).toLowerCase() + : null; +} + +/** Lazily mounted destination discovery for `experimental_FileLink`. */ +export function ExperimentalFileLinkMenu({ + intent, +}: { + intent: ExperimentalFileOpenOptions; +}) { + const navigation = useAppNavigationHost(); + const resolved = useResolvedLiveFileTarget(intent.target, { enabled: true }); + const localTargets = useLocalOpenTargets({ + enabled: resolved.status === "available", + ...(resolved.status === "available" + ? { openContext: resolved.openContext } + : {}), + }); + const { fileOpeners } = usePluginSlots(); + const extension = getFileExtension(intent.target.path); + const matchingOpeners = + extension === null + ? [] + : fileOpeners.filter((opener) => opener.extensions.includes(extension)); + const location = getExperimentalFileLocationStart(intent.location); + + return ( + <> + navigation.openFilePreview(intent)}> + Open preview + + {matchingOpeners.length > 0 ? ( + + Open with + + + navigation.openFilePreview({ ...intent, viewer: "builtin" }) + } + > + BB preview + + {matchingOpeners.map((opener) => ( + + navigation.openFilePreview({ + ...intent, + viewer: { + pluginId: opener.pluginId, + openerId: opener.id, + }, + }) + } + > + {opener.title} + + ))} + + + ) : null} + navigation.openFileExternally(intent)} + > + Open externally + + {resolved.status === "available" && + localTargets.fileOpenTargets.length > 0 ? ( + + Open in + + {localTargets.fileOpenTargets.map((target) => ( + { + void localTargets.openPathInFileTarget({ + columnNumber: location.columnNumber, + lineNumber: location.lineNumber, + path: resolved.absolutePath, + rememberTarget: false, + targetId: target.id, + }); + }} + > + {target.label} + + ))} + + + ) : null} + + { + void copyToClipboardWithToast( + resolved.status === "available" + ? resolved.absolutePath + : intent.target.path, + { + successMessage: "File path copied", + errorMessage: "Failed to copy file path", + }, + ); + }} + > + Copy file path + + { + void copyToClipboardWithToast(getFileBasename(intent.target.path), { + successMessage: "File name copied", + errorMessage: "Failed to copy file name", + }); + }} + > + Copy file name + + + ); +} diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx index ca292ff3d7..f25b924f99 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx @@ -21,6 +21,7 @@ import { } from "@/lib/fixed-panel-tabs-state"; import { PluginPanelRightPanelHost } from "./PluginPanelRightPanelHost"; import { getPluginPagePanelStateId } from "./plugin-page-panel-state"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; interface TestFixedTabRegistration { id: string; @@ -354,6 +355,95 @@ vi.mock("@/components/thread/terminal/ThreadTerminalPanel", async () => { }; }); +vi.mock("@/components/secondary-panel/ThreadSecondaryPanelTabContent", () => ({ + WorkspaceFilePreviewTabContent: ({ + activePath, + environmentId, + }: { + activePath: string; + environmentId: string; + }) => ( +
+ workspace:{environmentId}:{activePath} +
+ ), + HostScopedFilePreviewTabContent: ({ + activePath, + hostId, + }: { + activePath: string; + hostId: string; + }) => ( +
+ host:{hostId}:{activePath} +
+ ), + ThreadStorageFilePreviewTabContent: ({ + activePath, + threadId, + }: { + activePath: string; + threadId: string; + }) => ( +
+ storage:{threadId}:{activePath} +
+ ), +})); + +function FileIntentButtons() { + const navigation = useAppNavigationHost(); + return ( + <> + + + + + ); +} + function renderHost(panelPath = "board", subPath = "", store = createStore()) { const panelStateId = getPluginPagePanelStateId({ panelPath, @@ -373,6 +463,7 @@ function renderHost(panelPath = "board", subPath = "", store = createStore()) { subPath={subPath} >
Plugin page
+ @@ -505,6 +596,27 @@ describe("PluginPanelRightPanelHost", () => { ).toBe(false); }); + it("opens every explicit live-file identity through the shared panel host", async () => { + renderHost(); + + fireEvent.click( + screen.getByRole("button", { name: "Open workspace file" }), + ); + expect( + await screen.findByText("workspace:env-explicit:src/example.ts"), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Open host file" })); + expect( + await screen.findByText("host:host-explicit:/tmp/example.log"), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Open storage file" })); + expect( + await screen.findByText("storage:thr-explicit:reports/result.md"), + ).toBeTruthy(); + }); + it("does not reopen fixed tabs after navigating away and back", async () => { fixedTabState.registrations = [ { diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx index caeedccd1f..e523666664 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx @@ -22,9 +22,12 @@ import { PluginSlotMount } from "@/components/plugin/PluginSlotMount"; import { SecondaryPanelLayout } from "@/components/secondary-panel/SecondaryPanelLayout"; import { LazyBrowserTabDeck, + LazyHostScopedFilePreviewTabContent, LazyNewTabPage, LazyThreadSecondaryPanel, + LazyThreadStorageFilePreviewTabContent, LazyThreadTerminalPanel, + LazyWorkspaceFilePreviewTabContent, } from "@/components/secondary-panel/lazySecondaryPanelComponents"; import type { SecondaryPanelFixedTab } from "@/components/secondary-panel/ThreadSecondaryPanel"; import type { SecondaryPanelFileTab } from "@/components/secondary-panel/secondaryPanelFileTab"; @@ -58,12 +61,21 @@ import { getBrowserUrlHost } from "@/lib/browser-url"; import { isRoutePath } from "@/lib/route-paths"; import { UrlOpenRoutingProvider } from "@/lib/url-open-routing"; import { usePluginSlots } from "@/lib/plugin-slots"; +import { + AppNavigationHostProvider, + type AppFilePreviewIntent, +} from "@/lib/app-navigation-host"; +import { + normalizeExperimentalFileOpenOptions, + toFilePreviewLineRange, +} from "@/lib/live-file-navigation"; import { useOptionalPaneContext } from "@/views/thread-detail/PaneContext"; import { resolveTerminalHost, TerminalHostSelector, } from "@/components/secondary-panel/TerminalHostSelector"; import { getPluginPagePanelStateId } from "./plugin-page-panel-state"; +import { PluginPanelTabContent } from "./PluginPanelActions"; const TERMINAL_COLS = 100; const TERMINAL_ROWS = 30; @@ -181,6 +193,19 @@ export function PluginPanelRightPanelHost({ const { activateTab, activeBrowserTab, + activeFileOpenerOwner, + activeHostFileHostId, + activeHostFileLineRange, + activeHostFilePath, + activePluginPanelTab, + activeStorageFileLineRange, + activeStorageFilePath, + activeStorageFileThreadId, + activeWorkspaceFileEnvironmentId, + activeWorkspaceFileLineRange, + activeWorkspaceFilePath, + activeWorkspaceFileSource, + activeWorkspaceFileStatusLabel, browserTabs, closeTab, isNewTabActive, @@ -192,6 +217,8 @@ export function PluginPanelRightPanelHost({ panelStateId, syncThreadId: null, environmentId: null, + fileOwnerThreadId: null, + preserveWorkspaceTabsAcrossContexts: true, storageFiles: undefined, terminalSessions: undefined, }); @@ -247,6 +274,54 @@ export function PluginPanelRightPanelHost({ secondary: { ...state.secondary, isOpen: true }, })); }, [isCompactViewport, setCompactDrawerOpen, updatePanelState]); + const openFilePreview = useCallback( + (intent: AppFilePreviewIntent) => { + const normalized = normalizeExperimentalFileOpenOptions(intent); + if (normalized === null || panel === null) return false; + const lineRange = toFilePreviewLineRange(normalized.location); + const { target } = normalized; + const tab = + target.kind === "workspace" + ? openTab( + { + kind: "workspace-file-preview", + environmentId: target.environmentId, + tab: { + lineRange, + path: target.path, + source: { kind: "working-tree" }, + statusLabel: null, + }, + }, + { viewer: intent.viewer }, + ) + : target.kind === "host" + ? openTab( + { + kind: "host-file-preview", + hostId: target.hostId, + tab: { lineRange, path: target.path }, + }, + { viewer: intent.viewer }, + ) + : openTab( + { + kind: "thread-storage-file-preview", + threadId: target.threadId, + tab: { lineRange, path: target.path }, + }, + { viewer: intent.viewer }, + ); + if (tab === null) return false; + revealPanel(); + return true; + }, + [openTab, panel, revealPanel], + ); + const navigationCapabilities = useMemo( + () => ({ openFilePreview }), + [openFilePreview], + ); const hidePanel = useCallback(() => { if (isCompactViewport) { setCompactDrawerOpen(false); @@ -499,6 +574,47 @@ export function PluginPanelRightPanelHost({ onClose: () => closeTab(tab.id), }, ]; + case "workspace-file-preview": + case "host-file-preview": + case "thread-storage-file-preview": + return [ + { + id: tab.id, + filename: tab.path.split(/[\\/]/u).at(-1) ?? tab.path, + isActive: tab.id === activeTab?.id, + leadingVisual: , + statusLabel: + tab.kind === "workspace-file-preview" + ? tab.statusLabel + : null, + onSelect: () => { + activateTab(tab.id); + revealPanel(); + }, + onClose: () => closeTab(tab.id), + }, + ]; + case "plugin-panel": + return [ + { + id: tab.id, + filename: tab.title, + isActive: tab.id === activeTab?.id, + leadingVisual: ( + + ), + statusLabel: null, + onSelect: () => { + activateTab(tab.id); + revealPanel(); + }, + onClose: () => closeTab(tab.id), + }, + ]; default: return []; } @@ -514,62 +630,117 @@ export function PluginPanelRightPanelHost({ ], ); - const activeContent = useMemo( - () => - activeTerminalTab ? ( - - ) : isNewTabActive ? ( - undefined} - onSelect={() => undefined} - onOpenBrowser={ - isDesktopBrowserAvailable() ? () => openBrowser() : undefined - } - onStartTerminal={startSelectedTerminal} - showFileSearch={false} - startTerminalDisabled={ - createTerminal.isPending || - selectedTerminalHost?.status !== "connected" - } - startTerminalTrailing={ - - } + const activeContent = useMemo(() => { + const renderFileOpenerReplacement = (original: ReactNode): ReactNode => + activeFileOpenerOwner !== null && activePluginPanelTab !== null ? ( + - ) : null, - [ - activeTerminalTab, - activeTerminalTarget, - createTerminal.isPending, - hostsQuery.isLoading, - isNewTabActive, - isOpen, - openBrowser, - panelState.secondary.isOpen, - panelStateId, - selectedTerminalHost, - startSelectedTerminal, - terminalHosts, - ], - ); + ) : ( + original + ); + return activeTerminalTab ? ( + + ) : activeWorkspaceFilePath !== null && + activeWorkspaceFileEnvironmentId !== null ? ( + renderFileOpenerReplacement( + , + ) + ) : activeHostFilePath !== null && activeHostFileHostId !== null ? ( + renderFileOpenerReplacement( + , + ) + ) : activeStorageFilePath !== null && activeStorageFileThreadId !== null ? ( + renderFileOpenerReplacement( + , + ) + ) : isNewTabActive ? ( + undefined} + onSelect={() => undefined} + onOpenBrowser={ + isDesktopBrowserAvailable() ? () => openBrowser() : undefined + } + onStartTerminal={startSelectedTerminal} + showFileSearch={false} + startTerminalDisabled={ + createTerminal.isPending || + selectedTerminalHost?.status !== "connected" + } + startTerminalTrailing={ + + } + /> + ) : activePluginPanelTab !== null ? ( + + ) : null; + }, [ + activeFileOpenerOwner, + activeHostFileHostId, + activeHostFileLineRange, + activeHostFilePath, + activePluginPanelTab, + activeStorageFileLineRange, + activeStorageFilePath, + activeStorageFileThreadId, + activeTerminalTab, + activeTerminalTarget, + activeWorkspaceFileEnvironmentId, + activeWorkspaceFileLineRange, + activeWorkspaceFilePath, + activeWorkspaceFileSource, + activeWorkspaceFileStatusLabel, + createTerminal.isPending, + hostsQuery.isLoading, + isNewTabActive, + isOpen, + openBrowser, + panelState.secondary.isOpen, + panelStateId, + selectedTerminalHost, + startSelectedTerminal, + terminalHosts, + ]); const renderPanel = useCallback( ({ @@ -674,31 +845,33 @@ export function PluginPanelRightPanelHost({ - {panel !== null && - togglePortalTarget !== null && - !isOpen && - !isHostedBySplitWorkspace - ? createPortal( - - - - - {toggleLabel} - , - togglePortalTarget, - ) - : null} - {page} + + {panel !== null && + togglePortalTarget !== null && + !isOpen && + !isHostedBySplitWorkspace + ? createPortal( + + + + + {toggleLabel} + , + togglePortalTarget, + ) + : null} + {page} + ); } diff --git a/apps/app/src/components/plugin/file-opener-tabs.ts b/apps/app/src/components/plugin/file-opener-tabs.ts index 5b35e97efb..a17b61136f 100644 --- a/apps/app/src/components/plugin/file-opener-tabs.ts +++ b/apps/app/src/components/plugin/file-opener-tabs.ts @@ -168,33 +168,51 @@ function ownerRequestForOpenRequest({ switch (request.kind) { case "workspace-file-preview": { // Same guard as the built-in path, plus live-content-only rules. - if (resolvedEnvironmentId === undefined) return null; + if ( + request.environmentId === undefined && + resolvedEnvironmentId === undefined + ) { + return null; + } if (request.tab.source.kind !== "working-tree") return null; if (request.tab.statusLabel === "deleted") return null; + const environmentId = + request.environmentId ?? resolvedEnvironmentId ?? null; return { kind: request.kind, - environmentId: resolvedEnvironmentId, - projectId: resolvedEnvironmentId === null ? projectId : null, + environmentId, + projectId: environmentId === null ? projectId : null, tab: request.tab, threadId: threadId ?? null, }; } case "host-file-preview": { + if (request.hostId !== undefined) { + return { + kind: request.kind, + environmentId: null, + hostId: request.hostId, + tab: request.tab, + threadId: null, + }; + } if (!threadId || !resolvedEnvironmentId) return null; return { kind: request.kind, environmentId: resolvedEnvironmentId, + hostId: null, tab: request.tab, threadId, }; } case "thread-storage-file-preview": { - if (!threadId) return null; + const storageThreadId = request.threadId ?? threadId; + if (!storageThreadId) return null; return { kind: request.kind, environmentId: resolvedEnvironmentId ?? null, tab: request.tab, - threadId, + threadId: storageThreadId, }; } default: @@ -211,6 +229,7 @@ function fileForOwnerRequest( path: owner.tab.path, source: buildSource("workspace", { environmentId: owner.environmentId, + experimental_hostId: null, projectId: owner.projectId, threadId: owner.threadId, }), @@ -220,6 +239,7 @@ function fileForOwnerRequest( path: owner.tab.path, source: buildSource("host", { environmentId: owner.environmentId, + experimental_hostId: owner.hostId, projectId: null, threadId: owner.threadId, }), @@ -229,6 +249,7 @@ function fileForOwnerRequest( path: owner.tab.path, source: buildSource("thread-storage", { environmentId: owner.environmentId, + experimental_hostId: null, projectId: null, threadId: owner.threadId, }), @@ -240,6 +261,7 @@ function buildSource( kind: PluginFileOpenerSource["kind"], fields: { environmentId: string | null; + experimental_hostId: string | null; projectId: string | null; threadId: string | null; }, diff --git a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx index 0c34574f0a..6a49b35970 100644 --- a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx +++ b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx @@ -2062,6 +2062,7 @@ describe("plugin file opener tabs", () => { source: { kind: "workspace", environmentId: "env_1", + experimental_hostId: null, projectId: null, threadId: "thr_1", }, diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx index 9cf2b1b51c..ccb877558d 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx @@ -60,6 +60,7 @@ function createStoryFixedPanelTab( function createStoryFileTab(filename: string): HostFilePreviewFixedPanelTab { return { environmentId: "env_story", + hostId: null, id: `host-file-preview:${encodeURIComponent(filename)}:thread%3Athr_story%3Aenvironment%3Aenv_story`, kind: "host-file-preview", lineRange: null, diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx index 2d17fba303..71939327c5 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx @@ -13,6 +13,7 @@ import { useThreadHostFilePreview, useThreadStorageFilePreview, } from "@/hooks/queries/thread-queries"; +import { useHostFilePreview } from "@/hooks/queries/host-file-preview-query"; import { buildRawFilesystemHtmlContentUrl, buildThreadWorktreeRawContentUrl, @@ -126,6 +127,13 @@ export interface HostFilePreviewTabContentProps { threadId: string; } +export interface HostScopedFilePreviewTabContentProps { + activePath: string; + hostId: string; + lineRange: FilePreviewLineRange | null; + onOpenInEditor?: (path: string) => void; +} + export interface ThreadStorageFilePreviewTabContentProps { activePath: string; /** @@ -482,6 +490,36 @@ export function HostFilePreviewTabContent({ ); } +export function HostScopedFilePreviewTabContent({ + activePath, + hostId, + lineRange, + onOpenInEditor, +}: HostScopedFilePreviewTabContentProps) { + const { + data: hostFilePreview, + error, + isFetching, + isLoading, + refetch, + } = useHostFilePreview(hostId, activePath); + return ( + void refetch()} + statusLabel={null} + /> + ); +} + export function ThreadStorageFilePreviewTabContent({ activePath, copyPath = null, diff --git a/apps/app/src/components/secondary-panel/lazySecondaryPanelComponents.tsx b/apps/app/src/components/secondary-panel/lazySecondaryPanelComponents.tsx index a2b0427a9a..d173d69bfc 100644 --- a/apps/app/src/components/secondary-panel/lazySecondaryPanelComponents.tsx +++ b/apps/app/src/components/secondary-panel/lazySecondaryPanelComponents.tsx @@ -73,6 +73,13 @@ const HostFilePreviewTabContentChunk = lazy(() => }), ), ); +const HostScopedFilePreviewTabContentChunk = lazy(() => + import("./ThreadSecondaryPanelTabContent").then( + ({ HostScopedFilePreviewTabContent }) => ({ + default: HostScopedFilePreviewTabContent, + }), + ), +); const ProjectFilePreviewTabContentChunk = lazy(() => import("./ThreadSecondaryPanelTabContent").then( ({ ProjectFilePreviewTabContent }) => ({ @@ -252,6 +259,18 @@ export function LazyHostFilePreviewTabContent( ); } +export function LazyHostScopedFilePreviewTabContent( + props: ComponentProps< + ThreadSecondaryPanelTabContentModule["HostScopedFilePreviewTabContent"] + >, +) { + return ( + }> + + + ); +} + export function LazyProjectFilePreviewTabContent( props: ComponentProps< ThreadSecondaryPanelTabContentModule["ProjectFilePreviewTabContent"] diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts index c96b644ec0..d5922a4f14 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts @@ -535,6 +535,7 @@ describe("useThreadFileTabs file opener diversion", () => { expect(result.current.activeFileOpenerOwner).toEqual({ kind: "host-file-preview", environmentId: "env_1", + hostId: null, tab: { lineRange: { startLineNumber: 11, endLineNumber: 12 }, path: "/tmp/readme.md", diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index 499a3c1550..b287cd703b 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -94,9 +94,24 @@ export interface UpdateBrowserTabArgs { } export type OpenSecondaryPanelTabRequest = - | { kind: "workspace-file-preview"; tab: WorkspaceFileTabState } - | { kind: "host-file-preview"; tab: HostFileTabState } - | { kind: "thread-storage-file-preview"; tab: ThreadStorageFileTabState } + | { + kind: "workspace-file-preview"; + tab: WorkspaceFileTabState; + /** Explicit identity; omission preserves the surface-context adapter. */ + environmentId?: string; + } + | { + kind: "host-file-preview"; + tab: HostFileTabState; + /** Explicit identity; omission preserves the thread-context adapter. */ + hostId?: string; + } + | { + kind: "thread-storage-file-preview"; + tab: ThreadStorageFileTabState; + /** Explicit identity; omission preserves the thread-context adapter. */ + threadId?: string; + } | { kind: "browser"; url: string } | { kind: "new-tab" }; @@ -152,13 +167,28 @@ function createTabForOpenRequest({ }: CreateTabForOpenRequestArgs): SecondaryPanelTab | null { switch (request.kind) { case "workspace-file-preview": - if (resolvedEnvironmentId === undefined) return null; + if ( + request.environmentId === undefined && + resolvedEnvironmentId === undefined + ) { + return null; + } + const workspaceEnvironmentId = + request.environmentId ?? resolvedEnvironmentId ?? null; return createWorkspaceFilePreviewFixedPanelTab({ - environmentId: resolvedEnvironmentId, - projectId: resolvedEnvironmentId === null ? projectId : null, + environmentId: workspaceEnvironmentId, + projectId: workspaceEnvironmentId === null ? projectId : null, tab: request.tab, }); case "host-file-preview": + if (request.hostId !== undefined) { + return createHostFilePreviewFixedPanelTab({ + environmentId: null, + hostId: request.hostId, + tab: request.tab, + threadId: null, + }); + } if (!threadId || !resolvedEnvironmentId) return null; return createHostFilePreviewFixedPanelTab({ environmentId: resolvedEnvironmentId, @@ -166,11 +196,12 @@ function createTabForOpenRequest({ threadId, }); case "thread-storage-file-preview": - if (!threadId) return null; + const storageThreadId = request.threadId ?? threadId; + if (!storageThreadId) return null; return createStorageTab( resolvedEnvironmentId ?? null, request.tab, - threadId, + storageThreadId, ); case "browser": return createBrowserFixedPanelTab({ @@ -277,6 +308,7 @@ export function useThreadFileTabs({ ) { nextTab = createHostFilePreviewFixedPanelTab({ environmentId: resolvedEnvironmentId, + hostId: tab.hostId, tab: { lineRange: tab.lineRange, path: tab.path, @@ -652,6 +684,11 @@ export function useThreadFileTabs({ (activeFileOpenerOwner?.kind === "host-file-preview" ? activeFileOpenerOwner.environmentId : null), + activeHostFileHostId: + activeHostFileTab?.hostId ?? + (activeFileOpenerOwner?.kind === "host-file-preview" + ? activeFileOpenerOwner.hostId + : null), activeHostFileLineRange: activeHostFileTab?.lineRange ?? (activeFileOpenerOwner?.kind === "host-file-preview" diff --git a/apps/app/src/hooks/queries/host-file-preview-query.ts b/apps/app/src/hooks/queries/host-file-preview-query.ts new file mode 100644 index 0000000000..ff05642aee --- /dev/null +++ b/apps/app/src/hooks/queries/host-file-preview-query.ts @@ -0,0 +1,78 @@ +import { useQuery } from "@tanstack/react-query"; +import { sdk } from "@/lib/sdk"; +import { + buildFilePreview, + normalizeFilePreviewMimeType, + type FilePreview, +} from "@/lib/file-preview"; +import { hostFilePreviewQueryKey } from "./query-keys"; + +function decodeBase64Bytes(content: string): Uint8Array { + const binaryContent = atob(content); + const bytes = new Uint8Array(binaryContent.length); + for (let index = 0; index < binaryContent.length; index += 1) { + bytes[index] = binaryContent.charCodeAt(index); + } + return bytes; +} + +function encodeBase64Bytes(bytes: Uint8Array): string { + const chunkSize = 0x8000; + const binaryChunks: string[] = []; + for (let index = 0; index < bytes.length; index += chunkSize) { + binaryChunks.push( + String.fromCharCode(...bytes.subarray(index, index + chunkSize)), + ); + } + return btoa(binaryChunks.join("")); +} + +function splitAbsoluteHostFilePath(path: string): { + name: string; + rootPath: string; +} { + const lastSeparatorIndex = Math.max( + path.lastIndexOf("/"), + path.lastIndexOf("\\"), + ); + const name = path.slice(lastSeparatorIndex + 1); + let rootPath = path.slice(0, lastSeparatorIndex); + if (lastSeparatorIndex === 0) rootPath = "/"; + if (/^[A-Za-z]:$/u.test(rootPath)) { + rootPath = `${rootPath}${path[lastSeparatorIndex] ?? "\\"}`; + } + return { name, rootPath }; +} + +export function useHostFilePreview(hostId: string | null, path: string | null) { + const enabled = hostId !== null && path !== null; + return useQuery({ + queryKey: hostFilePreviewQueryKey(hostId, path), + queryFn: async ({ signal }) => { + if (hostId === null || path === null) { + throw new Error("Host file preview target is incomplete"); + } + const response = await sdk.files.read({ hostId, path, signal }); + const contentBytes = + response.contentEncoding === "base64" + ? decodeBase64Bytes(response.content) + : new TextEncoder().encode(response.content); + const mimeType = normalizeFilePreviewMimeType(response.mimeType ?? null); + const base64Content = + response.contentEncoding === "base64" + ? response.content + : encodeBase64Bytes(contentBytes); + const { name, rootPath } = splitAbsoluteHostFilePath(path); + const previewLease = await sdk.files + .createPreview({ hostId, rootPath, signal }) + .catch(() => null); + const url = + previewLease === null + ? `data:${mimeType};base64,${base64Content}` + : `${previewLease.baseUrl}/${encodeURIComponent(name)}`; + return buildFilePreview({ contentBytes, mimeType, name, path, url }); + }, + enabled, + staleTime: 30_000, + }); +} diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 6072b52ce5..35f1c5fbf9 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -40,6 +40,7 @@ export const THREAD_STORAGE_FILES_QUERY_KEY = "threadStorageFiles"; export const THREAD_STORAGE_PATHS_QUERY_KEY = "threadStoragePaths"; export const THREAD_STORAGE_FILE_PREVIEW_QUERY_KEY = "threadStorageFilePreview"; export const THREAD_HOST_FILE_PREVIEW_QUERY_KEY = "threadHostFilePreview"; +export const HOST_FILE_PREVIEW_QUERY_KEY = "hostFilePreview"; export const ENVIRONMENT_QUERY_KEY = "environment"; export const ENVIRONMENT_WORK_STATUS_QUERY_KEY = "environmentWorkStatus"; export const ENVIRONMENT_PULL_REQUEST_QUERY_KEY = "environmentPullRequest"; @@ -294,6 +295,11 @@ export type ThreadHostFilePreviewQueryKey = readonly [ export type AllThreadHostFilePreviewQueryKeyPrefix = readonly [ typeof THREAD_HOST_FILE_PREVIEW_QUERY_KEY, ]; +export type HostFilePreviewQueryKey = readonly [ + typeof HOST_FILE_PREVIEW_QUERY_KEY, + string | null, + string | null, +]; export type EnvironmentQueryKeyPrefix = readonly [typeof ENVIRONMENT_QUERY_KEY]; export type EnvironmentQueryKey = readonly [ typeof ENVIRONMENT_QUERY_KEY, @@ -814,6 +820,13 @@ export function threadHostFilePreviewQueryKey( return [THREAD_HOST_FILE_PREVIEW_QUERY_KEY, threadId, environmentId, path]; } +export function hostFilePreviewQueryKey( + hostId: string | null, + path: string | null, +): HostFilePreviewQueryKey { + return [HOST_FILE_PREVIEW_QUERY_KEY, hostId, path]; +} + export function allThreadHostFilePreviewQueryKeyPrefix(): AllThreadHostFilePreviewQueryKeyPrefix { return [THREAD_HOST_FILE_PREVIEW_QUERY_KEY]; } diff --git a/apps/app/src/hooks/useLocalOpenTargets.ts b/apps/app/src/hooks/useLocalOpenTargets.ts index 8f73144fb0..67fbd2ca92 100644 --- a/apps/app/src/hooks/useLocalOpenTargets.ts +++ b/apps/app/src/hooks/useLocalOpenTargets.ts @@ -58,6 +58,7 @@ export interface UseLocalOpenTargetsResult { canOpenPreferredFileTarget: boolean; directoryOpenTargets: WorkspaceOpenTarget[]; fileOpenTargets: WorkspaceOpenTarget[]; + isLoading: boolean; openPathInDirectoryTarget: ( args: OpenPathInDirectoryTargetArgs, ) => Promise; @@ -235,6 +236,7 @@ export function useLocalOpenTargets( const { hasDaemon } = useHostDaemon(); const { fetchWorkspaceOpenTargetsForPath, + isLoading, openWorkspace, workspaceOpenTargets, } = useWorkspaceOpenTargets(args); @@ -449,6 +451,7 @@ export function useLocalOpenTargets( canOpenPreferredFileTarget: preferredFileTarget !== null, directoryOpenTargets, fileOpenTargets, + isLoading, openPathInDirectoryTarget, openPathInFileTarget, openPathInPreferredDirectoryTarget, diff --git a/apps/app/src/hooks/useResolvedLiveFileTarget.ts b/apps/app/src/hooks/useResolvedLiveFileTarget.ts new file mode 100644 index 0000000000..43a29e5959 --- /dev/null +++ b/apps/app/src/hooks/useResolvedLiveFileTarget.ts @@ -0,0 +1,128 @@ +import { useMemo } from "react"; +import type { ExperimentalLiveFileTarget } from "@get-bb/plugin-sdk"; +import type { OpenInTargetContext } from "@bb/host-daemon-contract"; +import { useEnvironment } from "@/hooks/queries/environment-queries"; +import { + useThread, + useThreadStoragePaths, +} from "@/hooks/queries/thread-queries"; +import { useHostDaemon } from "@/hooks/useHostDaemon"; + +const STORAGE_ROOT_PATH_OPTIONS = { + includeDirectories: false, + includeFiles: true, + limit: 1, + query: null, +} as const; + +export type ResolvedLiveFileTarget = + | { status: "loading" } + | { status: "unavailable" } + | { + status: "available"; + absolutePath: string; + hostId: string; + openContext: OpenInTargetContext; + }; + +function buildAbsoluteHostPath(rootPath: string, relativePath: string): string { + const usesWindowsSeparators = + /^[A-Za-z]:[\\/]/u.test(rootPath) || rootPath.startsWith("\\\\"); + const separator = usesWindowsSeparators ? "\\" : "/"; + const normalizedRelativePath = usesWindowsSeparators + ? relativePath.replaceAll("/", "\\") + : relativePath; + const trimmedRootPath = rootPath.replace(/[\\/]+$/u, ""); + return `${trimmedRootPath}${separator}${normalizedRelativePath}`; +} + +export function useResolvedLiveFileTarget( + target: ExperimentalLiveFileTarget | null, + options: { enabled: boolean }, +): ResolvedLiveFileTarget { + const storageThreadId = + target?.kind === "thread-storage" ? target.threadId : ""; + const threadQuery = useThread(storageThreadId, { + enabled: options.enabled && storageThreadId.length > 0, + }); + const environmentId = + target?.kind === "workspace" + ? target.environmentId + : target?.kind === "thread-storage" + ? (threadQuery.data?.environmentId ?? "") + : ""; + const environmentQuery = useEnvironment(environmentId, { + enabled: options.enabled && environmentId.length > 0, + }); + const storageQuery = useThreadStoragePaths( + storageThreadId, + STORAGE_ROOT_PATH_OPTIONS, + { enabled: options.enabled && storageThreadId.length > 0 }, + ); + const { isLocalDaemonHost } = useHostDaemon(); + + return useMemo(() => { + if (!options.enabled || target === null) return { status: "unavailable" }; + if (target.kind === "host") { + return { + status: "available", + absolutePath: target.path, + hostId: target.hostId, + openContext: isLocalDaemonHost(target.hostId) + ? { kind: "local" } + : { + kind: "remote-ssh", + hostId: target.hostId, + serverOrigin: window.location.origin, + }, + }; + } + + if ( + (target.kind === "thread-storage" && threadQuery.isLoading) || + environmentQuery.isLoading || + (target.kind === "thread-storage" && storageQuery.isLoading) + ) { + return { status: "loading" }; + } + + const environment = environmentQuery.data; + if ( + environment === undefined || + environment.path === null || + (target.kind === "thread-storage" && + (threadQuery.isError || storageQuery.isError)) + ) { + return { status: "unavailable" }; + } + + const rootPath = + target.kind === "workspace" + ? environment.path + : storageQuery.data?.storageRootPath; + if (!rootPath) return { status: "unavailable" }; + return { + status: "available", + absolutePath: buildAbsoluteHostPath(rootPath, target.path), + hostId: environment.hostId, + openContext: isLocalDaemonHost(environment.hostId) + ? { kind: "local" } + : { + kind: "remote-ssh", + hostId: environment.hostId, + serverOrigin: window.location.origin, + }, + }; + }, [ + environmentQuery.data, + environmentQuery.isLoading, + isLocalDaemonHost, + options.enabled, + storageQuery.data?.storageRootPath, + storageQuery.isError, + storageQuery.isLoading, + target, + threadQuery.isError, + threadQuery.isLoading, + ]); +} diff --git a/apps/app/src/hooks/useWorkspaceOpenTargets.ts b/apps/app/src/hooks/useWorkspaceOpenTargets.ts index cbbc013842..41d6518a41 100644 --- a/apps/app/src/hooks/useWorkspaceOpenTargets.ts +++ b/apps/app/src/hooks/useWorkspaceOpenTargets.ts @@ -13,7 +13,7 @@ import { fetchWorkspaceOpenTargets, openInTarget as daemonOpenInTarget, } from "@/lib/api-host-daemon"; -import { useAsyncAtomValue } from "@/lib/use-async-atom-value"; +import { useAsyncAtomState } from "@/lib/use-async-atom-value"; const disabledLocalHostDaemonReachableAtom = atom(false); const disabledHostDaemonPortAtom = atom(null); @@ -29,28 +29,37 @@ export interface UseWorkspaceOpenTargetsResult { | ((path: string) => Promise) | null; openWorkspace: ((request: OpenInTargetRequest) => Promise) | null; + isLoading: boolean; workspaceOpenTargets: WorkspaceOpenTarget[]; } export function useWorkspaceOpenTargets( args: UseWorkspaceOpenTargetsArgs, ): UseWorkspaceOpenTargetsResult { - const localHostDaemonReachable = useAsyncAtomValue( + const localHostDaemonReachableState = useAsyncAtomState( args.enabled ? localHostDaemonReachableAtom : disabledLocalHostDaemonReachableAtom, false, ); - const daemonPort = useAsyncAtomValue( + const daemonPortState = useAsyncAtomState( args.enabled ? hostDaemonPortAtom : disabledHostDaemonPortAtom, null, ); - const workspaceOpenTargets = useAsyncAtomValue( + const workspaceOpenTargetsState = useAsyncAtomState( args.enabled ? localWorkspaceOpenTargetsAtom : disabledWorkspaceOpenTargetsAtom, NO_WORKSPACE_OPEN_TARGETS, ); + const localHostDaemonReachable = localHostDaemonReachableState.data; + const daemonPort = daemonPortState.data; + const workspaceOpenTargets = workspaceOpenTargetsState.data; + const isLoading = + args.enabled && + (localHostDaemonReachableState.isLoading || + daemonPortState.isLoading || + workspaceOpenTargetsState.isLoading); const openWorkspace = useMemo(() => { if ( @@ -80,6 +89,7 @@ export function useWorkspaceOpenTargets( return { fetchWorkspaceOpenTargetsForPath, + isLoading, openWorkspace, workspaceOpenTargets, }; diff --git a/apps/app/src/lib/app-navigation-host.tsx b/apps/app/src/lib/app-navigation-host.tsx index 650bb2d444..ed9d60c012 100644 --- a/apps/app/src/lib/app-navigation-host.tsx +++ b/apps/app/src/lib/app-navigation-host.tsx @@ -5,16 +5,27 @@ import { useMemo, type ReactNode, } from "react"; +import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import type { FileTabViewerOverride } from "@/components/plugin/file-opener-tabs"; export interface AppUrlOpenIntent { url: string; } +export interface AppFilePreviewIntent extends ExperimentalFileOpenOptions { + /** Internal per-activation override used by BB-owned Open with… menus. */ + viewer?: FileTabViewerOverride; +} + export interface AppNavigationHostCapabilities { + openFileExternally?: (intent: ExperimentalFileOpenOptions) => boolean; + openFilePreview?: (intent: AppFilePreviewIntent) => boolean; openUrl?: (intent: AppUrlOpenIntent) => boolean; } interface ResolvedAppNavigationHostCapabilities { + openFileExternally: ((intent: ExperimentalFileOpenOptions) => boolean) | null; + openFilePreview: ((intent: AppFilePreviewIntent) => boolean) | null; openUrl: ((intent: AppUrlOpenIntent) => boolean) | null; } @@ -35,9 +46,20 @@ export function AppNavigationHostProvider({ const parent = useContext(AppNavigationHostContext); const value = useMemo( () => ({ + openFileExternally: + capabilities.openFileExternally ?? parent?.openFileExternally ?? null, + openFilePreview: + capabilities.openFilePreview ?? parent?.openFilePreview ?? null, openUrl: capabilities.openUrl ?? parent?.openUrl ?? null, }), - [capabilities.openUrl, parent?.openUrl], + [ + capabilities.openFileExternally, + capabilities.openFilePreview, + capabilities.openUrl, + parent?.openFileExternally, + parent?.openFilePreview, + parent?.openUrl, + ], ); return ( @@ -49,9 +71,22 @@ export function AppNavigationHostProvider({ /** Semantic navigation intents accepted by the current app surface. */ export function useAppNavigationHost() { const host = useContext(AppNavigationHostContext); + const openFileExternally = useCallback( + (intent: ExperimentalFileOpenOptions): boolean => + host?.openFileExternally?.(intent) ?? false, + [host?.openFileExternally], + ); + const openFilePreview = useCallback( + (intent: AppFilePreviewIntent): boolean => + host?.openFilePreview?.(intent) ?? false, + [host?.openFilePreview], + ); const openUrl = useCallback( (intent: AppUrlOpenIntent): boolean => host?.openUrl?.(intent) ?? false, [host?.openUrl], ); - return useMemo(() => ({ openUrl }), [openUrl]); + return useMemo( + () => ({ openFileExternally, openFilePreview, openUrl }), + [openFileExternally, openFilePreview, openUrl], + ); } diff --git a/apps/app/src/lib/fixed-panel-tabs-state.test.ts b/apps/app/src/lib/fixed-panel-tabs-state.test.ts index 37009d833d..87b40cad94 100644 --- a/apps/app/src/lib/fixed-panel-tabs-state.test.ts +++ b/apps/app/src/lib/fixed-panel-tabs-state.test.ts @@ -377,6 +377,24 @@ describe("thread-owned file preview fixed panel tabs", () => { ); }); + it("does not collide explicit host previews for the same absolute path", () => { + const first = createHostFilePreviewFixedPanelTab({ + environmentId: null, + hostId: "host_first", + tab: { lineRange: null, path: "/tmp/log.txt" }, + threadId: null, + }); + const second = createHostFilePreviewFixedPanelTab({ + environmentId: null, + hostId: "host_second", + tab: { lineRange: null, path: "/tmp/log.txt" }, + threadId: null, + }); + + expect(first.id).not.toBe(second.id); + expect(areFixedPanelTabsEquivalent(first, second)).toBe(false); + }); + it("keeps legacy ownerless host and storage preview tabs parseable", () => { const state = { version: FIXED_PANEL_TABS_STATE_STORAGE_VERSION, @@ -411,6 +429,7 @@ describe("thread-owned file preview fixed panel tabs", () => { expect(parsed.secondary.tabs).toMatchObject([ { environmentId: null, + hostId: null, kind: "host-file-preview", threadId: null, }, diff --git a/apps/app/src/lib/live-file-navigation.test.ts b/apps/app/src/lib/live-file-navigation.test.ts new file mode 100644 index 0000000000..be915d1ad8 --- /dev/null +++ b/apps/app/src/lib/live-file-navigation.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeExperimentalFileOpenOptions, + normalizeExperimentalLiveFileTarget, + toFilePreviewLineRange, +} from "./live-file-navigation"; + +describe("normalizeExperimentalLiveFileTarget", () => { + it("accepts complete workspace, storage, POSIX host, and Windows host identities", () => { + expect( + normalizeExperimentalLiveFileTarget({ + kind: "workspace", + environmentId: "env_1", + path: "src/app.tsx", + }), + ).toEqual({ + kind: "workspace", + environmentId: "env_1", + path: "src/app.tsx", + }); + expect( + normalizeExperimentalLiveFileTarget({ + kind: "thread-storage", + threadId: "thr_1", + path: "reports/result.md", + }), + ).not.toBeNull(); + expect( + normalizeExperimentalLiveFileTarget({ + kind: "host", + hostId: "host_1", + path: "/tmp/output.log", + }), + ).not.toBeNull(); + expect( + normalizeExperimentalLiveFileTarget({ + kind: "host", + hostId: "host_1", + path: "C:\\work\\output.log", + }), + ).not.toBeNull(); + }); + + it.each([ + { kind: "workspace", environmentId: "env_1", path: "/src/app.tsx" }, + { kind: "workspace", environmentId: "env_1", path: "src/../app.tsx" }, + { kind: "thread-storage", threadId: "thr_1", path: "./result.md" }, + { kind: "host", hostId: "host_1", path: "relative/file.ts" }, + { kind: "host", hostId: "host_1", path: "/tmp/../secret" }, + { + kind: "workspace", + environmentId: "env_1", + path: "src/app.tsx", + ambientThreadId: "thr_1", + }, + ])("rejects ambiguous or non-exact target %#", (target) => { + expect(normalizeExperimentalLiveFileTarget(target)).toBeNull(); + }); +}); + +describe("normalizeExperimentalFileOpenOptions", () => { + it("rejects invalid locations instead of dropping them", () => { + expect( + normalizeExperimentalFileOpenOptions({ + target: { + kind: "workspace", + environmentId: "env_1", + path: "src/app.tsx", + }, + location: { kind: "range", startLine: 8, endLine: 4 }, + }), + ).toBeNull(); + }); + + it("maps a valid line location to the existing preview range", () => { + expect( + toFilePreviewLineRange({ kind: "line", line: 42, column: 7 }), + ).toEqual({ startLineNumber: 42, endLineNumber: 42 }); + }); +}); diff --git a/apps/app/src/lib/live-file-navigation.ts b/apps/app/src/lib/live-file-navigation.ts new file mode 100644 index 0000000000..4fe4a19709 --- /dev/null +++ b/apps/app/src/lib/live-file-navigation.ts @@ -0,0 +1,211 @@ +import type { + ExperimentalFileLocation, + ExperimentalFileOpenOptions, + ExperimentalLiveFileTarget, +} from "@get-bb/plugin-sdk"; +import type { FilePreviewLineRange } from "@/lib/file-preview"; + +const FILE_PATH_MAX_LENGTH = 32_768; +const WINDOWS_DRIVE_ABSOLUTE_PATH = /^[A-Za-z]:[\\/]/u; +const WINDOWS_UNC_ABSOLUTE_PATH = /^\\\\/u; + +function isJsonObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasExactKeys( + value: Record, + keys: readonly string[], +): boolean { + const actualKeys = Object.keys(value); + return ( + actualKeys.length === keys.length && + keys.every((key) => Object.prototype.hasOwnProperty.call(value, key)) + ); +} + +function isNonEmptyIdentity(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= FILE_PATH_MAX_LENGTH && + value.trim() === value + ); +} + +function hasControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint !== undefined && codePoint < 0x20) return true; + } + return false; +} + +function isValidPathSegment(segment: string): boolean { + return segment.length > 0 && segment !== "." && segment !== ".."; +} + +function isPositiveSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +function isValidRelativeFilePath(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > FILE_PATH_MAX_LENGTH || + value.trim() !== value || + value.includes("\\") || + hasControlCharacter(value) + ) { + return false; + } + return value.split("/").every(isValidPathSegment); +} + +function isValidAbsoluteHostFilePath(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > FILE_PATH_MAX_LENGTH || + value.trim() !== value || + hasControlCharacter(value) + ) { + return false; + } + + if (value.startsWith("/") && !value.startsWith("//")) { + const segments = value.slice(1).split("/"); + return segments.length > 0 && segments.every(isValidPathSegment); + } + + if (WINDOWS_DRIVE_ABSOLUTE_PATH.test(value)) { + const segments = value.slice(3).split(/[\\/]/u); + return segments.length > 0 && segments.every(isValidPathSegment); + } + + if (WINDOWS_UNC_ABSOLUTE_PATH.test(value)) { + const segments = value.slice(2).split(/[\\/]/u); + return segments.length >= 3 && segments.every(isValidPathSegment); + } + + return false; +} + +export function normalizeExperimentalLiveFileTarget( + value: unknown, +): ExperimentalLiveFileTarget | null { + if (!isJsonObject(value) || typeof value.kind !== "string") return null; + + switch (value.kind) { + case "workspace": + if ( + !hasExactKeys(value, ["kind", "environmentId", "path"]) || + !isNonEmptyIdentity(value.environmentId) || + !isValidRelativeFilePath(value.path) + ) { + return null; + } + return { + kind: value.kind, + environmentId: value.environmentId, + path: value.path, + }; + case "host": + if ( + !hasExactKeys(value, ["kind", "hostId", "path"]) || + !isNonEmptyIdentity(value.hostId) || + !isValidAbsoluteHostFilePath(value.path) + ) { + return null; + } + return { kind: value.kind, hostId: value.hostId, path: value.path }; + case "thread-storage": + if ( + !hasExactKeys(value, ["kind", "threadId", "path"]) || + !isNonEmptyIdentity(value.threadId) || + !isValidRelativeFilePath(value.path) + ) { + return null; + } + return { kind: value.kind, threadId: value.threadId, path: value.path }; + default: + return null; + } +} + +export function normalizeExperimentalFileLocation( + value: unknown, +): ExperimentalFileLocation | null | undefined { + if (value === null) return null; + if (!isJsonObject(value) || typeof value.kind !== "string") return undefined; + + switch (value.kind) { + case "line": + if ( + !hasExactKeys(value, ["kind", "line", "column"]) || + !isPositiveSafeInteger(value.line) || + (value.column !== null && !isPositiveSafeInteger(value.column)) + ) { + return undefined; + } + return { + kind: value.kind, + line: value.line, + column: value.column, + }; + case "range": + if ( + !hasExactKeys(value, ["kind", "startLine", "endLine"]) || + !isPositiveSafeInteger(value.startLine) || + !isPositiveSafeInteger(value.endLine) || + value.endLine < value.startLine + ) { + return undefined; + } + return { + kind: value.kind, + startLine: value.startLine, + endLine: value.endLine, + }; + default: + return undefined; + } +} + +export function normalizeExperimentalFileOpenOptions( + value: unknown, +): ExperimentalFileOpenOptions | null { + if (!isJsonObject(value) || !hasExactKeys(value, ["target", "location"])) { + return null; + } + const target = normalizeExperimentalLiveFileTarget(value.target); + const location = normalizeExperimentalFileLocation(value.location); + if (target === null || location === undefined) return null; + return { target, location }; +} + +export function getExperimentalFileLocationStart( + location: ExperimentalFileLocation | null, +): { columnNumber: number | null; lineNumber: number | null } { + if (location === null) return { columnNumber: null, lineNumber: null }; + if (location.kind === "line") { + return { columnNumber: location.column, lineNumber: location.line }; + } + return { columnNumber: null, lineNumber: location.startLine }; +} + +export function toFilePreviewLineRange( + location: ExperimentalFileLocation | null, +): FilePreviewLineRange | null { + if (location === null) return null; + return { + startLineNumber: + location.kind === "line" ? location.line : location.startLine, + endLineNumber: location.kind === "line" ? location.line : location.endLine, + }; +} diff --git a/apps/app/src/lib/plugin-sdk-app-impl.test.tsx b/apps/app/src/lib/plugin-sdk-app-impl.test.tsx index 0202f34322..72232a9618 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.test.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.test.tsx @@ -56,3 +56,32 @@ describe("plugin SDK Markdown", () => { expect(openUrl).toHaveBeenCalledWith({ url: "https://example.com/docs" }); }); }); + +describe("plugin SDK navigation components", () => { + it("exposes the file link through the real runtime", () => { + const openFilePreview = vi.fn(() => true); + const FileLink = pluginSdkAppImplementation.experimental_FileLink; + render( + + + result.md + + , + ); + fireEvent.click(screen.getByRole("link", { name: "result.md" })); + expect(openFilePreview).toHaveBeenCalledWith({ + target: { + kind: "thread-storage", + threadId: "thr_1", + path: "reports/result.md", + }, + location: null, + }); + }); +}); diff --git a/apps/app/src/lib/plugin-sdk-app-impl.tsx b/apps/app/src/lib/plugin-sdk-app-impl.tsx index 9f877c2d33..971839beb6 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.tsx @@ -5,6 +5,7 @@ import { PluginNewThreadComposer } from "@/components/plugin/PluginNewThreadComp import { PluginSourceCode } from "@/components/plugin/PluginSourceCode"; import { PluginThreadChat } from "@/components/plugin/PluginThreadChat"; import { ExperimentalUrlLink } from "@/components/plugin/ExperimentalUrlLink"; +import { ExperimentalFileLink } from "@/components/plugin/ExperimentalFileLink"; import { MarkdownPreview } from "@/components/ui/markdown-preview"; import type { MarkdownLinkRouting, @@ -60,6 +61,7 @@ export const pluginSdkAppImplementation = { // exception to §5.5) — stable product capabilities, not a UI kit. ThreadChat: PluginThreadChat, Markdown: PluginMarkdown, + experimental_FileLink: ExperimentalFileLink, experimental_UrlLink: ExperimentalUrlLink, // Experimental (see docs/api_to_audit.md): the create-side counterpart to // ThreadChat. diff --git a/apps/app/src/lib/plugin-sdk-hooks.ts b/apps/app/src/lib/plugin-sdk-hooks.ts index 23fa6dc324..e6d8a92533 100644 --- a/apps/app/src/lib/plugin-sdk-hooks.ts +++ b/apps/app/src/lib/plugin-sdk-hooks.ts @@ -53,6 +53,7 @@ import { useServerConnectionState } from "@/hooks/useServerConnectionState"; import { wsManager } from "@/lib/ws"; import { pluginSdkSettingsQueryKey } from "@/hooks/queries/query-keys"; import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { normalizeExperimentalFileOpenOptions } from "@/lib/live-file-navigation"; /** * Host implementations of the `@get-bb/plugin-sdk/app` hooks (plugin design @@ -340,6 +341,26 @@ export function useBbNavigate(): BbNavigate { (url) => appNavigation.openUrl({ url }), [appNavigation], ); + const experimental_openFilePreview = useCallback< + BbNavigate["experimental_openFilePreview"] + >( + (options) => { + const normalized = normalizeExperimentalFileOpenOptions(options); + return normalized !== null && appNavigation.openFilePreview(normalized); + }, + [appNavigation], + ); + const experimental_openFileExternally = useCallback< + BbNavigate["experimental_openFileExternally"] + >( + (options) => { + const normalized = normalizeExperimentalFileOpenOptions(options); + return ( + normalized !== null && appNavigation.openFileExternally(normalized) + ); + }, + [appNavigation], + ); return useMemo( () => ({ toThread, @@ -347,6 +368,8 @@ export function useBbNavigate(): BbNavigate { toPluginPanel, toCompose, openThreadPanel, + experimental_openFileExternally, + experimental_openFilePreview, experimental_openUrl, }), [ @@ -355,6 +378,8 @@ export function useBbNavigate(): BbNavigate { toPluginPanel, toCompose, openThreadPanel, + experimental_openFileExternally, + experimental_openFilePreview, experimental_openUrl, ], ); diff --git a/apps/app/src/lib/use-async-atom-value.ts b/apps/app/src/lib/use-async-atom-value.ts index a8f6f249bc..d0b2b470cd 100644 --- a/apps/app/src/lib/use-async-atom-value.ts +++ b/apps/app/src/lib/use-async-atom-value.ts @@ -36,6 +36,26 @@ export function useAsyncAtomValue( asyncAtom: Atom>, fallback: T, ): T { + return useAsyncAtomState(asyncAtom, fallback).data; +} + +export interface AsyncAtomState { + data: T; + error: unknown | null; + isLoading: boolean; +} + +/** Non-suspending async-atom state for consumers that must await discovery. */ +export function useAsyncAtomState( + asyncAtom: Atom>, + fallback: T, +): AsyncAtomState { const result = useAtomValue(loadableAtomFor(asyncAtom)); - return result.state === "hasData" ? result.data : fallback; + if (result.state === "hasData") { + return { data: result.data, error: null, isLoading: false }; + } + if (result.state === "hasError") { + return { data: fallback, error: result.error, isLoading: false }; + } + return { data: fallback, error: null, isLoading: true }; } diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 1bf418a99a..8b3f444a3a 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -66,6 +66,7 @@ import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; import { COARSE_POINTER_COMPACT_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { PluginIcon } from "@/components/plugin/PluginIcon"; +import type { FileTabViewerOverride } from "@/components/plugin/file-opener-tabs"; import { PluginPanelTabContent, usePluginNewThreadPanelActions, @@ -137,6 +138,14 @@ import { } from "@/lib/in-app-browser-link-preference"; import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link"; import { UrlOpenRoutingProvider } from "@/lib/url-open-routing"; +import { + AppNavigationHostProvider, + type AppFilePreviewIntent, +} from "@/lib/app-navigation-host"; +import { + normalizeExperimentalFileOpenOptions, + toFilePreviewLineRange, +} from "@/lib/live-file-navigation"; import { useRootComposeProjectId, useSetRootComposeProjectId, @@ -1265,21 +1274,27 @@ function RootComposeSurface({ environmentStatus: rootPanelEnvironment?.status, }); const openPersistedWorkspaceFile = useCallback( - (file: WorkspaceFileTabState) => { - openTab({ kind: "workspace-file-preview", tab: file }); + ( + file: WorkspaceFileTabState, + options?: { viewer?: FileTabViewerOverride }, + ) => { + openTab({ kind: "workspace-file-preview", tab: file }, options); }, [openTab], ); const openPersistedStorageFile = useCallback( - (file: ThreadStorageFileTabState) => { - openTab({ kind: "thread-storage-file-preview", tab: file }); + ( + file: ThreadStorageFileTabState, + options?: { viewer?: FileTabViewerOverride }, + ) => { + openTab({ kind: "thread-storage-file-preview", tab: file }, options); }, [openTab], ); const openPersistedHostFile = useCallback( - (file: HostFileTabState) => { - openTab({ kind: "host-file-preview", tab: file }); + (file: HostFileTabState, options) => { + openTab({ kind: "host-file-preview", tab: file }, options); }, [openTab], ); @@ -1302,6 +1317,7 @@ function RootComposeSurface({ const { closePanel: closeSecondaryPanel, openCompactDrawer, + openHostFile, openStorageFile, openWorkspaceFile, } = useThreadSecondaryPanelVisibility({ @@ -1318,6 +1334,56 @@ function RootComposeSurface({ openPersistedWorkspaceFile, togglePersistedPanel: toggleRootPersistedSecondaryPanel, }); + const handleOpenLiveFilePreview = useCallback( + (intent: AppFilePreviewIntent): boolean => { + const normalized = normalizeExperimentalFileOpenOptions(intent); + if (normalized === null) return false; + const lineRange = toFilePreviewLineRange(normalized.location); + const options = + intent.viewer === undefined ? undefined : { viewer: intent.viewer }; + switch (normalized.target.kind) { + case "workspace": + if (normalized.target.environmentId !== rootPanelEnvironmentId) { + return false; + } + openWorkspaceFile( + { + lineRange, + path: normalized.target.path, + source: { kind: "working-tree" }, + statusLabel: null, + }, + options, + ); + return true; + case "host": + if ( + rootPanelThreadId === null || + normalized.target.hostId !== rootPanelEnvironment?.hostId + ) { + return false; + } + openHostFile({ lineRange, path: normalized.target.path }, options); + return true; + case "thread-storage": + if (normalized.target.threadId !== rootPanelThreadId) return false; + openStorageFile({ lineRange, path: normalized.target.path }, options); + return true; + } + }, + [ + openHostFile, + openStorageFile, + openWorkspaceFile, + rootPanelEnvironment?.hostId, + rootPanelEnvironmentId, + rootPanelThreadId, + ], + ); + const appNavigationCapabilities = useMemo( + () => ({ openFilePreview: handleOpenLiveFilePreview }), + [handleOpenLiveFilePreview], + ); // Click handler for inserted mention pills in the root composer: threads // navigate, files open the root right-panel preview. Directories and commands // stay display-only. @@ -1342,29 +1408,38 @@ function RootComposeSurface({ if (rootPanelThreadId === null) { return null; } - return () => - openStorageFile({ - lineRange: null, - path: resource.path, + return () => { + handleOpenLiveFilePreview({ + target: { + kind: "thread-storage", + threadId: rootPanelThreadId, + path: resource.path, + }, + location: null, }); + }; } if (isProjectless) { return null; } - return () => - openWorkspaceFile({ - lineRange: null, - path: resource.path, - source: { kind: "working-tree" }, - statusLabel: null, + if (rootPanelEnvironmentId === null) return null; + return () => { + handleOpenLiveFilePreview({ + target: { + kind: "workspace", + environmentId: rootPanelEnvironmentId, + path: resource.path, + }, + location: null, }); + }; }, [ isProjectless, + handleOpenLiveFilePreview, navigate, - openStorageFile, - openWorkspaceFile, projectId, + rootPanelEnvironmentId, rootPanelThreadId, ], ); @@ -2357,75 +2432,77 @@ function RootComposeSurface({ : null } > - - candidate.pluginId === activePluginPanelTab.pluginId && - candidate.id === activePluginPanelTab.actionId, - )?.layout === "flush"), - renderBrowserDeck, - isBrowserTabActive, - isOpen: isSecondaryPanelOpen, - fixedTabs: [], - // The shell, tab strip, launcher, resize, and drawer behavior are - // shared with threads. Info, Diff, and conversation full-screen - // stay thread-only because no thread exists on this surface yet. - showConversationCollapseControl: false, - inlinePanelToggle: panelTogglePlacement.inlinePanelToggle, - onClose: closeSecondaryPanel, - onCollapse: closeSecondaryPanel, - onOpenFileInEditor: handleOpenWorkspaceFileInEditor, - onFileTabReorder: reorderFileTab, - onOpenNewTab: handleOpenNewTab, - onOpenFilePreview: handleOpenFilePreview, - onSelectionAddToChat: handleRootPanelSelectionAddToChat, - onPanelFocus: handleSecondaryPanelFocus, - }} - > - {showEmptyWelcome ? ( - - ) : ( - <> - {promptBox} - + + candidate.pluginId === activePluginPanelTab.pluginId && + candidate.id === activePluginPanelTab.actionId, + )?.layout === "flush"), + renderBrowserDeck, + isBrowserTabActive, + isOpen: isSecondaryPanelOpen, + fixedTabs: [], + // The shell, tab strip, launcher, resize, and drawer behavior are + // shared with threads. Info, Diff, and conversation full-screen + // stay thread-only because no thread exists on this surface yet. + showConversationCollapseControl: false, + inlinePanelToggle: panelTogglePlacement.inlinePanelToggle, + onClose: closeSecondaryPanel, + onCollapse: closeSecondaryPanel, + onOpenFileInEditor: handleOpenWorkspaceFileInEditor, + onFileTabReorder: reorderFileTab, + onOpenNewTab: handleOpenNewTab, + onOpenFilePreview: handleOpenFilePreview, + onSelectionAddToChat: handleRootPanelSelectionAddToChat, + onPanelFocus: handleSecondaryPanelFocus, + }} + > + {showEmptyWelcome ? ( + - - )} - + ) : ( + <> + {promptBox} + + + )} + + diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index fa3f734e7e..95cb639880 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -196,6 +196,14 @@ import { openUrlInExternalBrowser, UrlOpenRoutingProvider, } from "@/lib/url-open-routing"; +import { + AppNavigationHostProvider, + type AppFilePreviewIntent, +} from "@/lib/app-navigation-host"; +import { + normalizeExperimentalFileOpenOptions, + toFilePreviewLineRange, +} from "@/lib/live-file-navigation"; import { getFilePreviewLineRangeStart } from "@/lib/file-preview"; import { getBrowserUrlHost } from "@/lib/browser-url"; import { @@ -1302,6 +1310,50 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { openPersistedWorkspaceFile, togglePersistedPanel: toggleDefaultPersistedSecondaryPanel, }); + const handleOpenLiveFilePreview = useCallback( + (intent: AppFilePreviewIntent): boolean => { + const normalized = normalizeExperimentalFileOpenOptions(intent); + if (normalized === null || thread === undefined) return false; + const lineRange = toFilePreviewLineRange(normalized.location); + const options = + intent.viewer === undefined ? undefined : { viewer: intent.viewer }; + switch (normalized.target.kind) { + case "workspace": + if (normalized.target.environmentId !== thread.environmentId) { + return false; + } + openWorkspaceFile( + { + lineRange, + path: normalized.target.path, + source: { kind: "working-tree" }, + statusLabel: null, + }, + options, + ); + return true; + case "host": + if (normalized.target.hostId !== environment?.hostId) return false; + openHostFile({ lineRange, path: normalized.target.path }, options); + return true; + case "thread-storage": + if (normalized.target.threadId !== thread.id) return false; + openStorageFile({ lineRange, path: normalized.target.path }, options); + return true; + } + }, + [ + environment?.hostId, + openHostFile, + openStorageFile, + openWorkspaceFile, + thread, + ], + ); + const appNavigationCapabilities = useMemo( + () => ({ openFilePreview: handleOpenLiveFilePreview }), + [handleOpenLiveFilePreview], + ); const handleOpenTimelinePluginPanel = useCallback( ({ pluginId, actionId, title, params }) => { @@ -2399,14 +2451,22 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { ); const handleOpenFilePreview = useCallback( (relativePath) => { - openWorkspaceFile({ - lineRange: null, - path: relativePath, - source: { kind: "working-tree" }, - statusLabel: null, + if ( + thread?.environmentId === null || + thread?.environmentId === undefined + ) { + return; + } + handleOpenLiveFilePreview({ + target: { + kind: "workspace", + environmentId: thread.environmentId, + path: relativePath, + }, + location: null, }); }, - [openWorkspaceFile], + [handleOpenLiveFilePreview, thread?.environmentId], ); if (threadQueryState.status === "loading") { @@ -2771,6 +2831,7 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { ? { ...tab.fileOpenerOwner.tab, environmentId: tab.fileOpenerOwner.environmentId, + hostId: tab.fileOpenerOwner.hostId, id: `${tab.id}:file-opener-original`, kind: "host-file-preview", threadId: tab.fileOpenerOwner.threadId, @@ -2821,188 +2882,185 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { canOpenUrlsInAppBrowser ? openBrowserTabAndReveal : null } > - ( - - + ( + - {panel} - - - )} - metadata={{ - thread, - projectId, - parentThreadProjectId: parentThread?.projectId ?? null, - parentThreadDisplayName: parentThreadDisplayName ?? null, - parentThreads, - canAssignToParent, - canTakeOverThread, - isLoadingParentThreads: parentThreadSubsetQuery.isLoading, - isParentThreadsError: parentThreadSubsetQuery.isError, - environment: environment ?? null, - environmentDisplayHost: environmentDisplayHostContext, - workspaceStatus, - workspaceStatusError: workspaceStatusError ?? null, - workspaceUnavailable, - pullRequest, - selectedMergeBaseBranch, - mergeBaseBranchRef: selectedMergeBaseBranchRef, - mergeBaseBranchOptions, - mergeBaseRemoteBranchOptions, - isLoadingMergeBaseBranchOptions, - updateThreadPending: - updateThread.isPending || updateEnvironment.isPending, - storage: metadataStorage, - onAssignParent: handleAssignParent, - onParentSelectorOpenChange: handleParentSelectorOpenChange, - onRetryParentThreads: handleRetryParentThreads, - onMergeBaseBranchChange: handleMergeBaseBranchChange, - onMergeBasePickerOpenChange: handleMergeBasePickerOpenChange, - onMergeBaseBranchSearchQueryChange: setMergeBaseBranchSearchQuery, - onChangedFileClick: canUseGitUi - ? handleChangedFileClick - : undefined, - onCommitClick: canUseGitUi ? handleCommitClick : undefined, - }} - secondaryPanel={{ - activeTab: activeFixedSecondaryTab, - canUseGitUi, - gitDiffTabStatus, - environmentId: thread.environmentId ?? undefined, - workspaceRootPath: environment?.path, - fileTabs, - fileTabContent, - fileTabContentFillsRegion: - activePluginPanelTab !== null && - // A plugin `fileOpener` owns its own layout and scrolling, so it - // gets the definite-height region rather than the preview's - // scroll container — the same treatment as a "flush" action tab. - // Its actionId is `file-opener:`, which never matches a - // threadPanelAction, so it needs its own arm here. - (activePluginPanelTab.fileOpenerOwner !== undefined || - pluginThreadPanelActions.find( - (candidate) => - candidate.pluginId === activePluginPanelTab.pluginId && - candidate.id === activePluginPanelTab.actionId, - )?.layout === "flush"), - splitPanelStateId: thread.id, - splitTabModels: syncedOrderedSecondaryFileTabs, - renderSplitTabContent, - splitTabContentFillsRegion: (tab) => - tab.kind === "plugin-panel" && - (tab.fileOpenerOwner !== undefined || - pluginThreadPanelActions.find( - (candidate) => - candidate.pluginId === tab.pluginId && - candidate.id === tab.actionId, - )?.layout === "flush"), - renderBrowserDeck, - isBrowserTabActive, - isOpen: isSecondaryPanelOpen, - onClose: closeSecondaryPanel, - onCollapse: closeSecondaryPanel, - onClearPendingGitDiffIntent: clearPendingGitDiffIntent, - onOpenFileInEditor: handleOpenFileInEditor, - onFileTabReorder: reorderFileTab, - onOpenNewTab: handleOpenNewTab, - onRetryGitDiffEligibility: () => { - void environmentQuery.refetch(); - }, - onOpenFilePreview: handleOpenFilePreview, - onSelectionAddToChat: handleSelectionAddToChat, - pendingGitDiffCommitSha, - pendingGitDiffScrollPath, - requestedMergeBaseBranch, - onPanelFocus: handleSecondaryPanelFocus, - onPanelChange: handleSecondaryPanelChange, - }} - timeline={{ - activeThinking, - canSpawnChild: thread.canSpawnChild, - threadOriginKind, - hasOlderTimelineRows, - hostConnectionNotice, - isLoadingOlderTimelineRows, - isThreadTimelinePending, - timelineError: Boolean(timelineError), - onForkMessage: isForkAvailable ? handleForkMessage : undefined, - onEditMessage: canEditSentMessages - ? handleEditSentMessage - : undefined, - inlineMessageEditor, - onMessageAddToChat: handleSelectionAddToChat, - onSendToMainMessage: handleSendToMainMessage, - onSelectionAddToChat: handleSelectionAddToChat, - onLoadOlderRows: loadOlderTimelineRows, - onOpenLink: handleOpenTimelineLink, - onOpenLocalFileLink: handleOpenTimelineLocalFileLink, - onOpenPluginPanel: handleOpenTimelinePluginPanel, - onTitleAction: handleTimelineTitleAction, - projectId, - resolveMentionLink, - showOngoingIndicator: - thread.status !== "stopping" && - // A pending interaction (question or approval) already renders its - // own inline shimmer row, so the bottom indicator would just - // duplicate it. - !hasPendingInteraction && - isRunningThreadRuntimeDisplayStatus( - thread.runtime.displayStatus, - ) && - !isThreadTimelinePending, - ongoingIndicatorLabel: - thread.runtime.displayStatus === "host-reconnecting" - ? "Waiting for reconnection" + + {panel} + + + )} + metadata={{ + thread, + projectId, + parentThreadProjectId: parentThread?.projectId ?? null, + parentThreadDisplayName: parentThreadDisplayName ?? null, + parentThreads, + canAssignToParent, + canTakeOverThread, + isLoadingParentThreads: parentThreadSubsetQuery.isLoading, + isParentThreadsError: parentThreadSubsetQuery.isError, + environment: environment ?? null, + environmentDisplayHost: environmentDisplayHostContext, + workspaceStatus, + workspaceStatusError: workspaceStatusError ?? null, + workspaceUnavailable, + pullRequest, + selectedMergeBaseBranch, + mergeBaseBranchRef: selectedMergeBaseBranchRef, + mergeBaseBranchOptions, + mergeBaseRemoteBranchOptions, + isLoadingMergeBaseBranchOptions, + updateThreadPending: + updateThread.isPending || updateEnvironment.isPending, + storage: metadataStorage, + onAssignParent: handleAssignParent, + onParentSelectorOpenChange: handleParentSelectorOpenChange, + onRetryParentThreads: handleRetryParentThreads, + onMergeBaseBranchChange: handleMergeBaseBranchChange, + onMergeBasePickerOpenChange: handleMergeBasePickerOpenChange, + onMergeBaseBranchSearchQueryChange: setMergeBaseBranchSearchQuery, + onChangedFileClick: canUseGitUi + ? handleChangedFileClick : undefined, - timelineRows, - isStopping: thread.status === "stopping", - stoppingAnchorAt: thread.updatedAt, - threadId: thread.id, - threadRuntimeDisplayStatus: thread.runtime.displayStatus, - unreadDividerAutoScroll: unreadDividerState.autoScroll, - unreadDividerPlacement: unreadDividerState.placement, - workspaceRootPath: environment?.path ?? undefined, - }} - /> - {canUseGitUi ? ( - { - if (!open) { - gitActions.threadGitActionDialog.onClose(); - } + onCommitClick: canUseGitUi ? handleCommitClick : undefined, + }} + secondaryPanel={{ + activeTab: activeFixedSecondaryTab, + canUseGitUi, + gitDiffTabStatus, + environmentId: thread.environmentId ?? undefined, + workspaceRootPath: environment?.path, + fileTabs, + fileTabContent, + fileTabContentFillsRegion: + activePluginPanelTab !== null && + (activePluginPanelTab.fileOpenerOwner !== undefined || + pluginThreadPanelActions.find( + (candidate) => + candidate.pluginId === activePluginPanelTab.pluginId && + candidate.id === activePluginPanelTab.actionId, + )?.layout === "flush"), + splitPanelStateId: thread.id, + splitTabModels: syncedOrderedSecondaryFileTabs, + renderSplitTabContent, + splitTabContentFillsRegion: (tab) => + tab.kind === "plugin-panel" && + (tab.fileOpenerOwner !== undefined || + pluginThreadPanelActions.find( + (candidate) => + candidate.pluginId === tab.pluginId && + candidate.id === tab.actionId, + )?.layout === "flush"), + renderBrowserDeck, + isBrowserTabActive, + isOpen: isSecondaryPanelOpen, + onClose: closeSecondaryPanel, + onCollapse: closeSecondaryPanel, + onClearPendingGitDiffIntent: clearPendingGitDiffIntent, + onOpenFileInEditor: handleOpenFileInEditor, + onFileTabReorder: reorderFileTab, + onOpenNewTab: handleOpenNewTab, + onRetryGitDiffEligibility: () => { + void environmentQuery.refetch(); + }, + onOpenFilePreview: handleOpenFilePreview, + onSelectionAddToChat: handleSelectionAddToChat, + pendingGitDiffCommitSha, + pendingGitDiffScrollPath, + requestedMergeBaseBranch, + onPanelFocus: handleSecondaryPanelFocus, + onPanelChange: handleSecondaryPanelChange, + }} + timeline={{ + activeThinking, + canSpawnChild: thread.canSpawnChild, + threadOriginKind, + hasOlderTimelineRows, + hostConnectionNotice, + isLoadingOlderTimelineRows, + isThreadTimelinePending, + timelineError: Boolean(timelineError), + onForkMessage: isForkAvailable ? handleForkMessage : undefined, + onEditMessage: canEditSentMessages + ? handleEditSentMessage + : undefined, + inlineMessageEditor, + onMessageAddToChat: handleSelectionAddToChat, + onSendToMainMessage: handleSendToMainMessage, + onSelectionAddToChat: handleSelectionAddToChat, + onLoadOlderRows: loadOlderTimelineRows, + onOpenLink: handleOpenTimelineLink, + onOpenLocalFileLink: handleOpenTimelineLocalFileLink, + onOpenPluginPanel: handleOpenTimelinePluginPanel, + onTitleAction: handleTimelineTitleAction, + projectId, + resolveMentionLink, + showOngoingIndicator: + thread.status !== "stopping" && + // A pending interaction (question or approval) already renders its + // own inline shimmer row, so the bottom indicator would just + // duplicate it. + !hasPendingInteraction && + isRunningThreadRuntimeDisplayStatus( + thread.runtime.displayStatus, + ) && + !isThreadTimelinePending, + ongoingIndicatorLabel: + thread.runtime.displayStatus === "host-reconnecting" + ? "Waiting for reconnection" + : undefined, + timelineRows, + isStopping: thread.status === "stopping", + stoppingAnchorAt: thread.updatedAt, + threadId: thread.id, + threadRuntimeDisplayStatus: thread.runtime.displayStatus, + unreadDividerAutoScroll: unreadDividerState.autoScroll, + unreadDividerPlacement: unreadDividerState.placement, + workspaceRootPath: environment?.path ?? undefined, }} - onCommit={gitActions.handleCommitThread} - onSquashMerge={gitActions.handleSquashMergeThread} /> - ) : null} + {canUseGitUi ? ( + { + if (!open) { + gitActions.threadGitActionDialog.onClose(); + } + }} + onCommit={gitActions.handleCommitThread} + onSquashMerge={gitActions.handleSquashMergeThread} + /> + ) : null} + ); diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index d8e34326c5..b7434ac203 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -1257,6 +1257,7 @@ import { useSettings, useBbContext, useBbNavigate, + experimental_FileLink as FileLink, experimental_UrlLink as UrlLink, useComposer, useComposerView, @@ -1871,6 +1872,20 @@ className?, leadingContent?, messageActions? }` — normal browser behavior. Use `useBbNavigate().experimental_openUrl(url)` for buttons, menus, and effects; its boolean reports whether the current app accepted the intent, not whether a later OS launch completed. +- `experimental_FileLink` — a real anchor for an explicit live file target: + `{ kind: "workspace", environmentId, path }`, + `{ kind: "host", hostId, path }` (absolute), or + `{ kind: "thread-storage", threadId, path }`. Ordinary activation opens the + current surface's shared BB preview. Its lazy context menu offers the + built-in preview, matching plugin `fileOpener`s, the preferred external + target, available client apps, and copy actions. Optional `location` is a + one-based line/column or line range. For buttons and effects use + `useBbNavigate().experimental_openFilePreview({ target, location })` or + `.experimental_openFileExternally({ target, location })`; the boolean means + host acceptance, not completed I/O. Every identity is explicit—never invent + an environment id or turn a project id into a workspace target. The testing + harness records both calls in `navigateCalls` and gates them with the + `openFilePreview` / `openFileExternally` behavior options. - `experimental_NewThreadComposer` — bb's complete compose surface for CREATING a thread (the create-side counterpart to `ThreadChat`): prompt editor with @-mentions and expand, `+` attachments, diff --git a/apps/server/test/public/public-thread-tabs.test.ts b/apps/server/test/public/public-thread-tabs.test.ts index 8df7943239..6f1a492b1c 100644 --- a/apps/server/test/public/public-thread-tabs.test.ts +++ b/apps/server/test/public/public-thread-tabs.test.ts @@ -46,6 +46,7 @@ const ALL_TAB_KINDS: readonly ThreadTab[] = [ }, { environmentId: "env_1", + hostId: null, id: "host-file", kind: "host-file-preview", lineRange: null, diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 580997e649..0b069d7fd1 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -73,6 +73,37 @@ through the same navigation inspection log. 5. Keep the host implementation in the shell and verify plugin bundles contain only the runtime indirection, not BB browser or panel code. +## Live-file navigation (`experimental_FileLink`, `BbNavigate.experimental_openFilePreview`, `BbNavigate.experimental_openFileExternally`, and `PluginFileOpenerSource.experimental_hostId`) + +**What it does.** Gives plugin UI explicit, source-safe references to live +workspace, host, and thread-storage files. Ordinary `experimental_FileLink` +activation and the preview method use the current surface's shared file-tab +controller, including extension preferences and plugin file openers. The +external method resolves the current client's preferred file target, absolute +path, local/remote-SSH context, and line/column support. The boolean methods +report host acceptance; later OS failures remain host-owned. The host id added +to file-opener sources preserves explicit host identity when a plugin page +opens a host file without ambient thread context. + +**Audit before stabilizing.** + +1. Verify strict target/path/location validation on POSIX, Windows drive, and + UNC paths, including stale environment, host, and thread identities. +2. Confirm preview identity, persistence, opener preference, one-off Open with, + disabled opener fallback, and explicit-host migration on Thread, New-thread, + Settings, and plugin-page surfaces. +3. Audit external opening across local and remote clients, disconnected hosts, + missing preferred apps, and targets with line but not column support. +4. Confirm link anchor behavior, unavailable menu states, copy semantics, and + whether per-app external choices should remain host-owned menu affordances + rather than become plugin-selectable API. +5. Measure the lazy boundary: mounting a file link must not start file reads, + preview imports, editor discovery, or panel-destination loading. +6. Decide whether Git snapshots or deleted working-tree files merit separate + target variants; do not weaken live-file guarantees to accommodate them. +7. Confirm `PluginFileOpenerSource.experimental_hostId` can become a stable + required `hostId` field without breaking older opener implementations. + ## Host plugin foundation (`bb.hosts.experimental_client`, `ExperimentalHostClient.experimental_onWorkerExit`, `ExperimentalHostClient.experimental_onSignal`, `ExperimentalHostRpcContext.experimental_retainWorker`, `experimental_defineHostEntry`, and `experimental_createHostEntryHarness`) **What it does.** Lets one plugin package declare a singular `bb.host` Node diff --git a/packages/client-core/src/panel/fixed-panel-tabs-state.ts b/packages/client-core/src/panel/fixed-panel-tabs-state.ts index 260cc7535e..2521236931 100644 --- a/packages/client-core/src/panel/fixed-panel-tabs-state.ts +++ b/packages/client-core/src/panel/fixed-panel-tabs-state.ts @@ -94,6 +94,7 @@ const workspaceFilePreviewFixedPanelTabSchema = z const hostFilePreviewFixedPanelTabSchema = z .object({ environmentId: z.string().min(1).nullable().default(null), + hostId: z.string().min(1).nullable().default(null), id: z.string().min(1), kind: z.literal("host-file-preview"), lineRange: filePreviewLineRangeSchema.nullable().default(null), @@ -254,6 +255,7 @@ export interface WorkspaceFilePreviewFixedPanelTab { export interface HostFilePreviewFixedPanelTab { environmentId: string | null; + hostId: string | null; id: string; kind: "host-file-preview"; lineRange: FilePreviewLineRange | null; @@ -391,9 +393,10 @@ interface CreateThreadStorageFilePreviewFixedPanelTabArgs { } interface CreateHostFilePreviewFixedPanelTabArgs { - environmentId: string; + environmentId: string | null; + hostId?: string | null; tab: HostFileTabState; - threadId: string; + threadId: string | null; } interface CreateWorkspaceFilePreviewFixedPanelTabArgs { @@ -434,6 +437,7 @@ interface BuildWorkspaceFilePreviewTabIdArgs { interface BuildHostFilePreviewTabIdArgs { environmentId: string | null; + hostId: string | null; path: string; threadId: string | null; } @@ -488,9 +492,17 @@ function buildWorkspaceFilePreviewTabId({ function buildHostFilePreviewTabId({ environmentId, + hostId, path, threadId, }: BuildHostFilePreviewTabIdArgs): string { + if (hostId !== null) { + return buildFixedPanelTabId({ + environmentId: `host:${hostId}`, + kind: "host-file-preview", + path, + }); + } if (threadId === null || environmentId === null) { return buildFixedPanelTabId({ environmentId: null, @@ -594,13 +606,16 @@ export function createWorkspaceFilePreviewFixedPanelTab({ export function createHostFilePreviewFixedPanelTab({ environmentId, + hostId = null, tab, threadId, }: CreateHostFilePreviewFixedPanelTabArgs): HostFilePreviewFixedPanelTab { return { environmentId, + hostId, id: buildHostFilePreviewTabId({ environmentId, + hostId, path: tab.path, threadId, }), @@ -730,6 +745,7 @@ function normalizeFixedPanelTabId(tab: FixedPanelTab): FixedPanelTab { case "host-file-preview": { const id = buildHostFilePreviewTabId({ environmentId: tab.environmentId, + hostId: tab.hostId, path: tab.path, threadId: tab.threadId, }); @@ -1094,6 +1110,7 @@ export function areFixedPanelTabsEquivalent( return ( b.kind === "host-file-preview" && a.environmentId === b.environmentId && + a.hostId === b.hostId && areFilePreviewLineRangesEqual({ a: a.lineRange, b: b.lineRange, @@ -1146,6 +1163,9 @@ function areFileOpenerOwnersEqual( ) { return false; } + if (a.kind === "host-file-preview") { + return b.kind === "host-file-preview" && a.hostId === b.hostId; + } if (a.kind !== "workspace-file-preview") return true; return ( b.kind === "workspace-file-preview" && diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 70cc4501c2..b0858f292b 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -28,6 +28,16 @@ routes, modifier clicks, explicit anchor targets, and unsupported schemes stay native. The frontend harness records both forms in `navigateCalls` and accepts an `openUrl` behavior option. +Use `experimental_FileLink` for an explicit live workspace, host, or +thread-storage file. Ordinary activation opens the shared BB preview and its +context menu exposes built-in/plugin viewers, preferred external opening, and +copy actions. Buttons and menus can call +`experimental_openFilePreview({ target, location })` or +`experimental_openFileExternally({ target, location })`; both return whether +the current host accepted the intent. Targets never infer an ambient workspace. +The frontend harness records both methods and accepts `openFilePreview` and +`openFileExternally` behavior options. + Every panel-open entry point reports the same way: `openThreadPanel` and the `openPanel` handed to `threadPanelAction`, `experimental_newThreadPanelAction`, and `messageAction` `run` callbacks all return `boolean` — true when the host diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index 17e8ec2503..e41b426e70 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1571,6 +1571,32 @@ export interface ExperimentalUrlLinkProps extends Omit< href: string; } +/** A live file whose identity is complete without ambient route context. */ +export type ExperimentalLiveFileTarget = + | { kind: "workspace"; environmentId: string; path: string } + | { kind: "host"; hostId: string; path: string } + | { kind: "thread-storage"; threadId: string; path: string }; + +/** One-based location to reveal after a live file opens. */ +export type ExperimentalFileLocation = + | { kind: "line"; line: number; column: number | null } + | { kind: "range"; startLine: number; endLine: number }; + +/** Options shared by BB's preview and preferred-external file intents. */ +export interface ExperimentalFileOpenOptions { + target: ExperimentalLiveFileTarget; + location: ExperimentalFileLocation | null; +} + +/** Props for BB's host-rendered semantic file link. */ +export interface ExperimentalFileLinkProps extends Omit< + ComponentPropsWithoutRef<"a">, + "href" | "target" +> { + target: ExperimentalLiveFileTarget; + location?: ExperimentalFileLocation | null; +} + /** Current app selection, derived from the route. */ export interface BbContext { projectId: string | null; @@ -1609,6 +1635,12 @@ export interface BbNavigate { * docs/api_to_audit.md. */ experimental_openUrl(url: string): boolean; + /** Open a live file in this surface's shared BB preview panel. */ + experimental_openFilePreview(options: ExperimentalFileOpenOptions): boolean; + /** Open a live file in this client's preferred external file target. */ + experimental_openFileExternally( + options: ExperimentalFileOpenOptions, + ): boolean; } // --------------------------------------------------------------------------- @@ -1706,6 +1738,8 @@ export interface PluginSdkApp { * Experimental: see docs/api_to_audit.md. */ experimental_UrlLink: ComponentType; + /** Host-rendered live-file link backed by the shared navigation controller. */ + experimental_FileLink: ComponentType; /** * The host-owned new-thread compose surface (see * {@link NewThreadComposerProps}). Experimental: see diff --git a/packages/plugin-sdk/src/app.ts b/packages/plugin-sdk/src/app.ts index 21e3e9021d..a4b32254e5 100644 --- a/packages/plugin-sdk/src/app.ts +++ b/packages/plugin-sdk/src/app.ts @@ -48,6 +48,7 @@ const runtime = ((globalThis as PluginRuntimeHost).__bbPluginRuntime export const definePluginApp = runtime.definePluginApp; export const ThreadChat = runtime.ThreadChat; export const Markdown = runtime.Markdown; +export const experimental_FileLink = runtime.experimental_FileLink; export const experimental_UrlLink = runtime.experimental_UrlLink; export const experimental_NewThreadComposer = runtime.experimental_NewThreadComposer; diff --git a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx index db804126f3..13da601dd6 100644 --- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx +++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx @@ -22,6 +22,7 @@ import { defineRpcContract } from "../../rpc-contract.js"; installTestPluginRuntime(); const { definePluginApp, + experimental_FileLink: FileLink, experimental_UrlLink: UrlLink, ThreadChat, useBbNavigate, @@ -103,6 +104,30 @@ function UrlNavigationProbe() { ); } +const fileIntent = { + target: { + kind: "workspace" as const, + environmentId: "env_42", + path: "src/example.ts", + }, + location: { kind: "line" as const, line: 12, column: 4 }, +}; + +function FileNavigationProbe() { + const navigate = useBbNavigate(); + return ( +
+ Open file + +
+ ); +} + let capturedComposerVisualSetters: Pick< PluginComposerApi, "setTextEffect" | "setInputLock" @@ -975,6 +1000,23 @@ describe("renderSlot", () => { ]); }); + it("records file-link preview and imperative external intents through one host boundary", () => { + const slot = renderSlot( + { component: FileNavigationProbe }, + {}, + { + openFilePreview: () => true, + openFileExternally: () => true, + }, + ); + fireEvent.click(slot.getByRole("link", { name: "Open file" })); + fireEvent.click(slot.getByRole("button", { name: "Open file externally" })); + expect(slot.inspection.navigateCalls).toEqual([ + { method: "experimental_openFilePreview", options: fileIntent }, + { method: "experimental_openFileExternally", options: fileIntent }, + ]); + }); + it("drives the shared realtime connection lifecycle", async () => { const slot = renderSlot( app.homepageSections[0]!, diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index 6341f960b8..7d9ed89c18 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -55,6 +55,8 @@ import { type StandardSchemaV1InferInput, type MarkdownProps, type ExperimentalUrlLinkProps, + type ExperimentalFileLinkProps, + type ExperimentalFileOpenOptions, type NewThreadComposerProps, type ThreadChatProps, type DiffProps, @@ -114,7 +116,15 @@ export type NavigateCall = method: "openThreadPanel"; options: Parameters[0]; } - | { method: "experimental_openUrl"; url: string }; + | { method: "experimental_openUrl"; url: string } + | { + method: "experimental_openFilePreview"; + options: ExperimentalFileOpenOptions; + } + | { + method: "experimental_openFileExternally"; + options: ExperimentalFileOpenOptions; + }; export interface ComposerLog { /** Latest plain text in this isolated composer scope. */ @@ -326,6 +336,39 @@ function TestUrlLink({ ); } +/** Anchor-faithful file-link stand-in backed by the navigation recorder. */ +function TestFileLink({ + target, + location = null, + onClick, + ...anchorProps +}: ExperimentalFileLinkProps) { + const navigate = useSlotEnv("experimental_FileLink").navigate; + const options = { target, location }; + return ( + ) => { + onClick?.(event); + if ( + event.defaultPrevented || + event.button !== 0 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.currentTarget.hasAttribute("download") + ) { + return; + } + event.preventDefault(); + navigate.experimental_openFilePreview(options); + }} + /> + ); +} + /** * Stand-in for the host-owned new-thread composer: a textarea plus a submit * button that calls `onSubmit` with a fixed, obviously-synthetic request, so @@ -520,6 +563,7 @@ const testPluginSdkApp = { }, ThreadChat: TestThreadChat, Markdown: TestMarkdown, + experimental_FileLink: TestFileLink, experimental_UrlLink: TestUrlLink, experimental_NewThreadComposer: TestNewThreadComposer, experimental_SourceCode: TestSourceCode, @@ -838,6 +882,10 @@ export interface RenderSlotOptions< ) => boolean; /** Host acceptance for URL intents from the hook or `experimental_UrlLink`. */ openUrl?: (url: string) => boolean; + /** Host acceptance for preview intents from the hook or file link. */ + openFilePreview?: (options: ExperimentalFileOpenOptions) => boolean; + /** Host acceptance for preferred-external file intents. */ + openFileExternally?: (options: ExperimentalFileOpenOptions) => boolean; } /** Host-originated inputs a slot test can drive deterministically. */ @@ -1054,6 +1102,20 @@ export function renderSlot< navigateCalls.push({ method: "experimental_openUrl", url }); return options.openUrl?.(url) ?? false; }, + experimental_openFilePreview(fileOptions) { + navigateCalls.push({ + method: "experimental_openFilePreview", + options: fileOptions, + }); + return options.openFilePreview?.(fileOptions) ?? false; + }, + experimental_openFileExternally(fileOptions) { + navigateCalls.push({ + method: "experimental_openFileExternally", + options: fileOptions, + }); + return options.openFileExternally?.(fileOptions) ?? false; + }, }; const projectId = options.context?.projectId ?? null; diff --git a/packages/server-contract/src/api/thread-tabs.ts b/packages/server-contract/src/api/thread-tabs.ts index 1bf9a3bfd1..57f20bf336 100644 --- a/packages/server-contract/src/api/thread-tabs.ts +++ b/packages/server-contract/src/api/thread-tabs.ts @@ -56,7 +56,8 @@ export const threadTabFileOpenerOwnerSchema = z.discriminatedUnion("kind", [ .strict(), z .object({ - environmentId: z.string().min(1), + environmentId: z.string().min(1).nullable().default(null), + hostId: z.string().min(1).nullable().default(null), kind: z.literal("host-file-preview"), tab: z .object({ @@ -64,9 +65,15 @@ export const threadTabFileOpenerOwnerSchema = z.discriminatedUnion("kind", [ path: threadTabPathSchema, }) .strict(), - threadId: z.string().min(1), + threadId: z.string().min(1).nullable().default(null), }) - .strict(), + .strict() + .refine( + (owner) => + owner.hostId !== null || + (owner.environmentId !== null && owner.threadId !== null), + { message: "hostId or threadId/environmentId is required" }, + ), z .object({ environmentId: z.string().min(1).nullable(), @@ -114,6 +121,7 @@ export const threadTabSchema = z.discriminatedUnion("kind", [ z .object({ environmentId: z.string().min(1).nullable(), + hostId: z.string().min(1).nullable().default(null), id: threadTabIdSchema, kind: z.literal("host-file-preview"), lineRange: threadTabLineRangeSchema.nullable(), diff --git a/packages/server-contract/test/thread-tabs.test.ts b/packages/server-contract/test/thread-tabs.test.ts index 1840b4aa35..5a9f739e6f 100644 --- a/packages/server-contract/test/thread-tabs.test.ts +++ b/packages/server-contract/test/thread-tabs.test.ts @@ -38,6 +38,7 @@ const OWNERS = [ }, { environmentId: "env_docs", + hostId: null, kind: "host-file-preview", tab: { lineRange: null, path: "/Users/dev/notes.md" }, threadId: "thr_docs", @@ -84,13 +85,27 @@ describe("thread tab file-opener owner", () => { const result = threadTabsSchema.safeParse([ { ...OPENER_TAB_BASE, - // `host-file-preview` owners require a concrete environment id. + // A host owner needs either an explicit host or its legacy thread pair. fileOpenerOwner: { ...OWNERS[1], environmentId: null }, }, ]); expect(result.success).toBe(false); }); + + it("accepts an explicit host owner without ambient thread context", () => { + const fileOpenerOwner = { + ...OWNERS[1], + environmentId: null, + hostId: "host_docs", + threadId: null, + }; + const parsed = threadTabsSchema.parse([ + { ...OPENER_TAB_BASE, fileOpenerOwner }, + ]); + + expect(parsed[0]).toEqual({ ...OPENER_TAB_BASE, fileOpenerOwner }); + }); }); const TERMINAL_TAB_BASE = { diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 66eb53ace9..97b7eae4c7 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -520,7 +520,10 @@ connecting/connected/reconnecting lifecycle; reconcile on later connected transitions, not the initial connection), useSettings (secrets excluded), useBbContext, useBbNavigate (including experimental_openUrl(url), which applies the current -client's in-app/external-browser preference), useComposer +client's in-app/external-browser preference, plus +experimental_openFilePreview({ target, location }) and +experimental_openFileExternally({ target, location }) for explicit live +workspace/host/thread-storage files), useComposer (read/replace/update/clear scoped composer text, apply a class-based text effect, lock input, quote selections, insert mention pills, and focus the composer), and useComposerView (reactive bound scope, @@ -539,6 +542,10 @@ running BB via the pinned ref in components.json). Product capabilities are the exception: experimental_UrlLink renders a real anchor whose ordinary HTTP(S) activation uses the same client preference as first-party links while leaving app routes, modifiers, copying, and unsupported schemes native. +experimental_FileLink renders a real explicit live-file anchor whose ordinary +activation uses the same preview/file-opener controller as first-party links; +its lazy context menu adds Open with, preferred-external, installed-app, and +copy actions without reading the file or discovering editors on mount. `import { toast } from "sonner"` reaches the host toaster; react, the portaling radix families, sonner, vaul, @pierre/diffs, and the host-resident clsx, tailwind-merge, and diff --git a/plugins/docs/app.test.tsx b/plugins/docs/app.test.tsx index f1fe5133b0..0ccf7cfe79 100644 --- a/plugins/docs/app.test.tsx +++ b/plugins/docs/app.test.tsx @@ -1281,6 +1281,7 @@ describe("Docs nav panel", () => { kind: "workspace", threadId: "thr_1", environmentId: "env_1", + experimental_hostId: null, projectId: "project_1", }, experimental_Original: () => null, diff --git a/plugins/docs/app.tsx b/plugins/docs/app.tsx index 611e517141..a2ce86417d 100644 --- a/plugins/docs/app.tsx +++ b/plugins/docs/app.tsx @@ -8,6 +8,7 @@ import { } from "react"; import { definePluginApp, + experimental_FileLink as FileLink, useBbNavigate, useRpc, useRealtime, @@ -15,6 +16,7 @@ import { type PluginMessageDirectiveProps, type PluginNavPanelProps, type PluginThreadPanelProps, + type ExperimentalLiveFileTarget, } from "@get-bb/plugin-sdk/app"; import type { docsRpcContract } from "./server.js"; import { parseMarkdownDocument } from "./markdown-document.js"; @@ -1139,6 +1141,31 @@ function NotePane({ function DocsFileOpener({ path: filePath, source }: PluginFileOpenerProps) { const rpc = useRpc(); + const navigate = useBbNavigate(); + const liveFileTarget = useMemo(() => { + switch (source.kind) { + case "workspace": + return source.environmentId === null + ? null + : { + kind: source.kind, + environmentId: source.environmentId, + path: filePath, + }; + case "host": + return source.experimental_hostId === null + ? null + : { + kind: source.kind, + hostId: source.experimental_hostId, + path: filePath, + }; + case "thread-storage": + return source.threadId === null + ? null + : { kind: source.kind, threadId: source.threadId, path: filePath }; + } + }, [filePath, source]); const openerSource = useMemo( () => ({ kind: source.kind, @@ -1250,6 +1277,28 @@ function DocsFileOpener({ path: filePath, source }: PluginFileOpenerProps) { } return (
+ {liveFileTarget === null ? null : ( +
+ + {filePath} + + +
+ )} {conflict ? (
Changed on disk. diff --git a/plugins/github/app.tsx b/plugins/github/app.tsx index 3baaade8fa..de88128965 100644 --- a/plugins/github/app.tsx +++ b/plugins/github/app.tsx @@ -19,6 +19,7 @@ import { import { definePluginApp, experimental_Diff as Diff, + experimental_FileLink as FileLink, experimental_UrlLink as UrlLink, useBbNavigate, useRealtime, @@ -1385,18 +1386,43 @@ function ChecksSection({ checks }: { checks: PullCheck[] }) { ); } -function FileDiffCard({ file, url }: { file: PullFile; url: string }) { +function FileDiffCard({ + environmentId, + file, + url, +}: { + environmentId: string | null; + file: PullFile; + url: string; +}) { const [open, setOpen] = useState(false); return (
- + {environmentId === null ? ( + + {file.path} + + ) : ( + + {file.path} + + )} {file.status !== "modified" ? ( {file.status} @@ -1408,7 +1434,7 @@ function FileDiffCard({ file, url }: { file: PullFile; url: string }) { −{file.deletions} - +
{open ? ( file.patch !== null ? (
@@ -1577,12 +1603,14 @@ function PullDetailView({ onBack, backLabel = "Pull requests", compact = false, + workspaceEnvironmentId = null, }: { repo: string; number: number; onBack?: () => void; backLabel?: string; compact?: boolean; + workspaceEnvironmentId?: string | null; }) { const rpc = useRpc(); const links = useLinks(); @@ -1643,7 +1671,12 @@ function PullDetailView({ {pull.files.map((file) => ( - + ))}
) : null} @@ -1797,16 +1830,35 @@ function PullPickerList({ onPick }: { onPick: (repo: string, number: number) => function PullPanelTab({ threadId }: PluginThreadPanelProps) { const rpc = useRpc(); const [resolved, setResolved] = useState(false); - const [selected, setSelected] = useState<{ repo: string; number: number } | null>(null); + const [selected, setSelected] = useState<{ + repo: string; + number: number; + environmentId: string | null; + } | null>(null); useEffect(() => { let cancelled = false; rpc.call("pullForThread", { threadId }).then( (result) => { if (cancelled) return; - const pull = (result as { pull?: { repo?: unknown; number?: unknown } | null })?.pull; + const pull = ( + result as { + pull?: { + repo?: unknown; + number?: unknown; + environmentId?: unknown; + } | null; + } + )?.pull; if (pull && typeof pull.repo === "string" && typeof pull.number === "number") { - setSelected({ repo: pull.repo, number: pull.number }); + setSelected({ + repo: pull.repo, + number: pull.number, + environmentId: + typeof pull.environmentId === "string" + ? pull.environmentId + : null, + }); } setResolved(true); }, @@ -1828,7 +1880,11 @@ function PullPanelTab({ threadId }: PluginThreadPanelProps) {

No pull request is linked to this thread yet — pick one:

- setSelected({ repo, number })} /> + + setSelected({ repo, number, environmentId: null }) + } + />
); } @@ -1837,6 +1893,7 @@ function PullPanelTab({ threadId }: PluginThreadPanelProps) { repo={selected.repo} number={selected.number} compact + workspaceEnvironmentId={selected.environmentId} backLabel="All PRs" onBack={() => setSelected(null)} /> diff --git a/plugins/github/server.ts b/plugins/github/server.ts index f6c8a19a31..f368de857c 100644 --- a/plugins/github/server.ts +++ b/plugins/github/server.ts @@ -228,7 +228,18 @@ export const githubRpcContract = defineRpcContract({ }, pullForThread: { input: z.object({ threadId: z.string().min(1) }).strict(), - output: z.object({ pull: itemInputSchema.nullable() }).strict(), + output: z + .object({ + pull: z + .object({ + repo: repoNameSchema, + number: itemNumberSchema, + environmentId: z.string().nullable(), + }) + .strict() + .nullable(), + }) + .strict(), }, commentIssue: { input: itemInputSchema.extend({ body: nonBlankStringSchema }).strict(), @@ -1365,11 +1376,13 @@ export default async function plugin(bb: BbPluginApi) { environment PR (the branch the agent pushed) first, else a PR this thread was spawned to review. Null when neither exists. */ async pullForThread({ threadId }) { + let environmentId: string | null = null; try { const thread = (await bb.sdk.threads.get({ threadId })) as unknown as { environmentId?: string | null; }; if (thread?.environmentId) { + environmentId = thread.environmentId; const result = await bb.sdk.environments.pullRequest({ environmentId: thread.environmentId, }); @@ -1380,7 +1393,13 @@ export default async function plugin(bb: BbPluginApi) { ? url.match(/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/) : null; if (match !== null) { - return { pull: { repo: match[1], number: Number(match[2]) } }; + return { + pull: { + repo: match[1], + number: Number(match[2]), + environmentId, + }, + }; } } } catch { @@ -1391,7 +1410,13 @@ export default async function plugin(bb: BbPluginApi) { const match = key.match(/^pr:([\w.-]+\/[\w.-]+)#(\d+)$/); if (match === null) continue; if (threadLinks.some((link) => link.threadId === threadId)) { - return { pull: { repo: match[1], number: Number(match[2]) } }; + return { + pull: { + repo: match[1], + number: Number(match[2]), + environmentId, + }, + }; } } return { pull: null }; From f7de5350bade0aa83da064464d16f6d8a2cd5a75 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 18 Aug 2026 23:23:14 -0700 Subject: [PATCH 03/18] Stabilize lazy file navigation coverage --- .../plugin/AppFileExternalNavigationHost.test.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx index 41fb3297a2..fbd27cbe52 100644 --- a/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx +++ b/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx @@ -64,12 +64,14 @@ describe("AppFileExternalNavigationHost", () => { , ); fireEvent.click(screen.getByRole("button", { name: "Open external" })); - await waitFor(() => - expect(openPreferred).toHaveBeenCalledWith({ - columnNumber: 3, - lineNumber: 12, - path: "/workspace/src/example.ts", - }), + await waitFor( + () => + expect(openPreferred).toHaveBeenCalledWith({ + columnNumber: 3, + lineNumber: 12, + path: "/workspace/src/example.ts", + }), + { timeout: 5_000 }, ); }); }); From d88287285c06b92ce37f15059885d70a348b6dc5 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 18 Aug 2026 23:31:29 -0700 Subject: [PATCH 04/18] Add generic fixed-tab navigation --- .../plugin/PluginPanelRightPanelHost.test.tsx | 106 ++++++++++++ .../plugin/PluginPanelRightPanelHost.tsx | 120 ++++++++++++-- .../git-diff-fixed-tab-navigation.test.ts | 36 ++++ .../git-diff/git-diff-fixed-tab-navigation.ts | 71 ++++++++ .../thread-info-fixed-tab-navigation.ts | 20 +++ .../src/lib/app-fixed-tab-navigation.test.ts | 45 +++++ apps/app/src/lib/app-fixed-tab-navigation.tsx | 69 ++++++++ apps/app/src/lib/app-navigation-host.tsx | 74 +++++---- apps/app/src/lib/plugin-sdk-app-impl.tsx | 4 + apps/app/src/lib/plugin-sdk-hooks.ts | 56 +++++++ apps/app/src/views/RootComposeView.tsx | 8 +- .../views/thread-detail/ThreadDetailView.tsx | 84 +++++++++- .../bb-plugin-authoring/SKILL.md | 29 +++- docs/api_to_audit.md | 34 +++- examples/plugins/thread-chat-demo/README.md | 6 + examples/plugins/thread-chat-demo/app.tsx | 76 ++++++++- packages/plugin-sdk/README.md | 12 ++ .../src/__tests__/fixed-tab-types.test.ts | 63 +++++++ packages/plugin-sdk/src/app-contract.ts | 97 +++++++++-- packages/plugin-sdk/src/app.ts | 3 + .../src/internal/plugin-app-collector.ts | 28 ++++ .../testing/__tests__/app-harness.test.tsx | 154 ++++++++++++++++++ packages/plugin-sdk/src/testing/app.tsx | 128 +++++++++++++++ .../src/templates/bb-guide-plugins.md | 7 + plugins/github/app.test.tsx | 34 ++++ plugins/github/app.tsx | 95 ++++++++++- 26 files changed, 1394 insertions(+), 65 deletions(-) create mode 100644 apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts create mode 100644 apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.ts create mode 100644 apps/app/src/components/secondary-panel/thread-info-fixed-tab-navigation.ts create mode 100644 apps/app/src/lib/app-fixed-tab-navigation.test.ts create mode 100644 apps/app/src/lib/app-fixed-tab-navigation.tsx create mode 100644 packages/plugin-sdk/src/__tests__/fixed-tab-types.test.ts create mode 100644 plugins/github/app.test.tsx diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx index f25b924f99..c94b74569d 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx @@ -22,12 +22,19 @@ import { import { PluginPanelRightPanelHost } from "./PluginPanelRightPanelHost"; import { getPluginPagePanelStateId } from "./plugin-page-panel-state"; import { useAppNavigationHost } from "@/lib/app-navigation-host"; +import { + getPluginFixedTabOwnerId, + useAppFixedTabTarget, +} from "@/lib/app-fixed-tab-navigation"; interface TestFixedTabRegistration { id: string; title: string; icon: string; component: (props: { subPath: string }) => ReactNode; + experimental_target?: { + validate(value: import("@get-bb/plugin-sdk").JsonValue): boolean; + }; layout?: "padded" | "flush"; } @@ -440,6 +447,36 @@ function FileIntentButtons() { > Open storage file + + ); } @@ -596,6 +633,75 @@ describe("PluginPanelRightPanelHost", () => { ).toBe(false); }); + it("validates and transiently delivers a plugin-owned fixed-tab target", async () => { + function Details() { + const delivery = useAppFixedTabTarget( + getPluginFixedTabOwnerId("demo", "board"), + "details", + ); + return ( +
+ Details + {delivery === null ? null : ( + <> + {JSON.stringify(delivery.target)} + + + )} +
+ ); + } + fixedTabState.registrations = [ + { + id: "navigation", + title: "Navigation", + icon: "PanelRight", + component: () =>
Navigation
, + }, + { + id: "details", + title: "Details", + icon: "Info", + component: Details, + experimental_target: { + validate: (value) => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + value.kind === "record" && + typeof value.recordId === "string", + }, + }, + ]; + + renderHost(); + expect(await screen.findByTestId("navigation-content")).toBeTruthy(); + + fireEvent.click( + screen.getByRole("button", { name: "Open invalid fixed tab target" }), + ); + expect(screen.getByTestId("navigation-content")).toBeTruthy(); + expect(screen.queryByTestId("targeted-details-content")).toBeNull(); + + fireEvent.click( + screen.getByRole("button", { name: "Open targeted fixed tab" }), + ); + expect(await screen.findByTestId("targeted-details-content")).toBeTruthy(); + expect( + screen.getByText('{"kind":"record","recordId":"issue-42"}'), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Consume target" })); + expect(screen.queryByRole("button", { name: "Consume target" })).toBeNull(); + const persistedValues = Array.from( + { length: localStorage.length }, + (_, index) => localStorage.getItem(localStorage.key(index) ?? "") ?? "", + ).join("\n"); + expect(persistedValues).not.toContain("issue-42"); + }); + it("opens every explicit live-file identity through the shared panel host", async () => { renderHost(); diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx index e523666664..cb3899edef 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx @@ -3,6 +3,7 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; @@ -10,6 +11,7 @@ import { createPortal } from "react-dom"; import { atom, useAtom } from "jotai"; import { atomFamily } from "jotai-family"; import type { Host } from "@bb/domain"; +import { jsonValueSchema } from "@bb/domain"; import { Button } from "@bb/shared-ui/button"; import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { COARSE_POINTER_HEADER_ICON_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; @@ -65,6 +67,13 @@ import { AppNavigationHostProvider, type AppFilePreviewIntent, } from "@/lib/app-navigation-host"; +import { + AppFixedTabTargetProvider, + getPluginFixedTabOwnerId, + openAppFixedTabFromDestinations, + type AppFixedTabDestination, + type AppFixedTabTargetDelivery, +} from "@/lib/app-fixed-tab-navigation"; import { normalizeExperimentalFileOpenOptions, toFilePreviewLineRange, @@ -274,6 +283,82 @@ export function PluginPanelRightPanelHost({ secondary: { ...state.secondary, isOpen: true }, })); }, [isCompactViewport, setCompactDrawerOpen, updatePanelState]); + const [fixedTabTargetDelivery, setFixedTabTargetDelivery] = + useState(null); + const fixedTabTargetSequenceRef = useRef(0); + const fixedTabOwnerId = getPluginFixedTabOwnerId( + pluginId, + panel?.id ?? panelPath, + ); + useEffect(() => { + setFixedTabTargetDelivery(null); + }, [fixedTabOwnerId]); + const fixedTabDestinations = useMemo( + () => + (panel?.experimental_fixedTabs ?? []).flatMap((registration) => { + const tab = fixedViewTabs.find( + (candidate) => candidate.fixedTabId === registration.id, + ); + if (tab === undefined) return []; + return [ + { + tab: { + ownerId: fixedTabOwnerId, + tabId: registration.id, + }, + open: (target) => { + if (target === undefined) { + setFixedTabTargetDelivery(null); + } else { + const result = jsonValueSchema.safeParse(target); + if ( + !result.success || + registration.experimental_target === undefined + ) { + return false; + } + try { + if (!registration.experimental_target.validate(result.data)) { + return false; + } + } catch { + return false; + } + fixedTabTargetSequenceRef.current += 1; + const sequence = fixedTabTargetSequenceRef.current; + setFixedTabTargetDelivery({ + consume: () => + setFixedTabTargetDelivery((current) => + current?.sequence === sequence ? null : current, + ), + ownerId: fixedTabOwnerId, + sequence, + tabId: registration.id, + target: result.data, + }); + } + updatePanelState((state) => + activateSecondaryPanelTabInState(state, tab.id), + ); + revealPanel(); + return true; + }, + }, + ]; + }), + [ + fixedViewTabs, + fixedTabOwnerId, + panel?.experimental_fixedTabs, + revealPanel, + updatePanelState, + ], + ); + const openFixedTab = useCallback( + (intent: Parameters[1]) => + openAppFixedTabFromDestinations(fixedTabDestinations, intent), + [fixedTabDestinations], + ); const openFilePreview = useCallback( (intent: AppFilePreviewIntent) => { const normalized = normalizeExperimentalFileOpenOptions(intent); @@ -319,8 +404,8 @@ export function PluginPanelRightPanelHost({ [openTab, panel, revealPanel], ); const navigationCapabilities = useMemo( - () => ({ openFilePreview }), - [openFilePreview], + () => ({ openFilePreview, openFixedTab }), + [openFilePreview, openFixedTab], ); const hidePanel = useCallback(() => { if (isCompactViewport) { @@ -470,12 +555,14 @@ export function PluginPanelRightPanelHost({ className="size-3.5" /> ), - onSelect: () => { - updatePanelState((state) => - activateSecondaryPanelTabInState(state, tab.id), - ); - revealPanel(); - }, + onSelect: () => + openFixedTab({ + surface: { kind: "current" }, + tab: { + ownerId: fixedTabOwnerId, + tabId: registration.id, + }, + }), tab, title: registration.title, }, @@ -483,10 +570,10 @@ export function PluginPanelRightPanelHost({ }), [ fixedViewTabs, + fixedTabOwnerId, + openFixedTab, panel?.experimental_fixedTabs, pluginId, - revealPanel, - updatePanelState, ], ); const activeFixedTabRegistration = @@ -510,10 +597,19 @@ export function PluginPanelRightPanelHost({ slotId={activeFixedTabRegistration.id} instanceId={panel.id} > - + + + ); - }, [activeFixedTabRegistration, isOpen, panel, pluginId, subPath]); + }, [ + activeFixedTabRegistration, + fixedTabTargetDelivery, + isOpen, + panel, + pluginId, + subPath, + ]); const fileTabs = useMemo( () => diff --git a/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts new file mode 100644 index 0000000000..a3058a7788 --- /dev/null +++ b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { createGitDiffFixedTabDestination } from "./git-diff-fixed-tab-navigation"; + +describe("createGitDiffFixedTabDestination", () => { + it("owns file and commit target validation outside the generic controller", () => { + const openCommit = vi.fn(); + const openFile = vi.fn(); + const openOrdinary = vi.fn(); + const destination = createGitDiffFixedTabDestination({ + eligible: true, + openCommit, + openFile, + openOrdinary, + }); + + expect(destination.open({ kind: "file", path: "src/app.tsx" })).toBe(true); + expect(destination.open({ kind: "commit", sha: "abc123" })).toBe(true); + expect(destination.open({ kind: "file", path: "" })).toBe(false); + expect(destination.open(undefined)).toBe(true); + expect(openFile).toHaveBeenCalledWith("src/app.tsx"); + expect(openCommit).toHaveBeenCalledWith("abc123"); + expect(openOrdinary).toHaveBeenCalledOnce(); + }); + + it("declines every target while Changes is ineligible", () => { + const openOrdinary = vi.fn(); + const destination = createGitDiffFixedTabDestination({ + eligible: false, + openCommit: vi.fn(), + openFile: vi.fn(), + openOrdinary, + }); + expect(destination.open(undefined)).toBe(false); + expect(openOrdinary).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.ts b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.ts new file mode 100644 index 0000000000..d830fb91e0 --- /dev/null +++ b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.ts @@ -0,0 +1,71 @@ +import type { JsonValue } from "@get-bb/plugin-sdk"; +import type { AppFixedTabDestination } from "@/lib/app-fixed-tab-navigation"; +import type { AppFixedTabReference } from "@/lib/app-navigation-host"; + +export type GitDiffFixedTabTarget = + | { kind: "file"; path: string } + | { kind: "commit"; sha: string }; + +export const GIT_DIFF_FIXED_TAB_REFERENCE: AppFixedTabReference = { + ownerId: "core:git-diff", + tabId: "changes", +}; + +function normalizeGitDiffFixedTabTarget( + value: JsonValue, +): GitDiffFixedTabTarget | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + const keys = Object.keys(value); + if ( + value.kind === "file" && + keys.length === 2 && + keys.includes("kind") && + keys.includes("path") && + typeof value.path === "string" && + value.path.length > 0 + ) { + return { kind: value.kind, path: value.path }; + } + if ( + value.kind === "commit" && + keys.length === 2 && + keys.includes("kind") && + keys.includes("sha") && + typeof value.sha === "string" && + value.sha.length > 0 + ) { + return { kind: value.kind, sha: value.sha }; + } + return null; +} + +/** The Changes owner validates and interprets targets outside the controller. */ +export function createGitDiffFixedTabDestination({ + eligible, + openCommit, + openFile, + openOrdinary, +}: { + eligible: boolean; + openCommit: (sha: string) => void; + openFile: (path: string) => void; + openOrdinary: () => void; +}): AppFixedTabDestination { + return { + tab: GIT_DIFF_FIXED_TAB_REFERENCE, + open(target) { + if (!eligible) return false; + if (target === undefined) { + openOrdinary(); + return true; + } + const normalized = normalizeGitDiffFixedTabTarget(target); + if (normalized === null) return false; + if (normalized.kind === "file") openFile(normalized.path); + else openCommit(normalized.sha); + return true; + }, + }; +} diff --git a/apps/app/src/components/secondary-panel/thread-info-fixed-tab-navigation.ts b/apps/app/src/components/secondary-panel/thread-info-fixed-tab-navigation.ts new file mode 100644 index 0000000000..357be3a214 --- /dev/null +++ b/apps/app/src/components/secondary-panel/thread-info-fixed-tab-navigation.ts @@ -0,0 +1,20 @@ +import type { AppFixedTabDestination } from "@/lib/app-fixed-tab-navigation"; +import type { AppFixedTabReference } from "@/lib/app-navigation-host"; + +export const THREAD_INFO_FIXED_TAB_REFERENCE: AppFixedTabReference = { + ownerId: "core:thread-info", + tabId: "info", +}; + +export function createThreadInfoFixedTabDestination( + open: () => void, +): AppFixedTabDestination { + return { + tab: THREAD_INFO_FIXED_TAB_REFERENCE, + open(target) { + if (target !== undefined) return false; + open(); + return true; + }, + }; +} diff --git a/apps/app/src/lib/app-fixed-tab-navigation.test.ts b/apps/app/src/lib/app-fixed-tab-navigation.test.ts new file mode 100644 index 0000000000..d944c17287 --- /dev/null +++ b/apps/app/src/lib/app-fixed-tab-navigation.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { openAppFixedTabFromDestinations } from "./app-fixed-tab-navigation"; + +describe("openAppFixedTabFromDestinations", () => { + it("resolves an owner-scoped reference and forwards the target unchanged", () => { + const open = vi.fn(() => true); + const target = { kind: "record", recordId: "issue-42" } as const; + + expect( + openAppFixedTabFromDestinations( + [ + { + tab: { ownerId: "plugin:demo", tabId: "details" }, + open, + }, + ], + { + surface: { kind: "current" }, + tab: { ownerId: "plugin:demo", tabId: "details" }, + target, + }, + ), + ).toBe(true); + expect(open).toHaveBeenCalledWith(target); + }); + + it("leaves destinations untouched when owner or tab is ineligible", () => { + const open = vi.fn(() => true); + expect( + openAppFixedTabFromDestinations( + [ + { + tab: { ownerId: "plugin:other", tabId: "details" }, + open, + }, + ], + { + surface: { kind: "current" }, + tab: { ownerId: "plugin:demo", tabId: "details" }, + }, + ), + ).toBe(false); + expect(open).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/lib/app-fixed-tab-navigation.tsx b/apps/app/src/lib/app-fixed-tab-navigation.tsx new file mode 100644 index 0000000000..15e1c2d933 --- /dev/null +++ b/apps/app/src/lib/app-fixed-tab-navigation.tsx @@ -0,0 +1,69 @@ +import { createContext, useContext, type ReactNode } from "react"; +import type { JsonValue } from "@get-bb/plugin-sdk"; +import type { + AppFixedTabOpenIntent, + AppFixedTabReference, +} from "@/lib/app-navigation-host"; + +export interface AppFixedTabDestination { + open(target: JsonValue | undefined): boolean; + tab: AppFixedTabReference; +} + +export function getPluginFixedTabOwnerId( + pluginId: string, + panelId: string, +): string { + return `plugin:${pluginId}:${panelId}`; +} + +/** + * Generic fixed-tab transition. Destination owners validate and interpret + * targets; this controller only resolves an owner-scoped reference. + */ +export function openAppFixedTabFromDestinations( + destinations: readonly AppFixedTabDestination[], + intent: AppFixedTabOpenIntent, +): boolean { + const destination = destinations.find( + (candidate) => + candidate.tab.ownerId === intent.tab.ownerId && + candidate.tab.tabId === intent.tab.tabId, + ); + return destination?.open(intent.target) ?? false; +} + +export interface AppFixedTabTargetDelivery { + consume(): void; + ownerId: string; + sequence: number; + tabId: string; + target: JsonValue; +} + +const AppFixedTabTargetContext = + createContext(null); + +export function AppFixedTabTargetProvider({ + children, + delivery, +}: { + children: ReactNode; + delivery: AppFixedTabTargetDelivery | null; +}) { + return ( + + {children} + + ); +} + +export function useAppFixedTabTarget( + ownerId: string, + tabId: string, +): AppFixedTabTargetDelivery | null { + const delivery = useContext(AppFixedTabTargetContext); + return delivery?.ownerId === ownerId && delivery.tabId === tabId + ? delivery + : null; +} diff --git a/apps/app/src/lib/app-navigation-host.tsx b/apps/app/src/lib/app-navigation-host.tsx index ed9d60c012..d9d7ed83ef 100644 --- a/apps/app/src/lib/app-navigation-host.tsx +++ b/apps/app/src/lib/app-navigation-host.tsx @@ -1,11 +1,14 @@ import { createContext, - useCallback, useContext, useMemo, type ReactNode, } from "react"; -import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import type { + ExperimentalAppPanelSurface, + ExperimentalFileOpenOptions, + JsonValue, +} from "@get-bb/plugin-sdk"; import type { FileTabViewerOverride } from "@/components/plugin/file-opener-tabs"; export interface AppUrlOpenIntent { @@ -17,20 +20,44 @@ export interface AppFilePreviewIntent extends ExperimentalFileOpenOptions { viewer?: FileTabViewerOverride; } +/** Internal identity. Public plugin callers never supply `ownerId`. */ +export interface AppFixedTabReference { + ownerId: string; + tabId: string; +} + +export interface AppFixedTabOpenIntent { + surface: ExperimentalAppPanelSurface; + tab: AppFixedTabReference; + target?: JsonValue; +} + export interface AppNavigationHostCapabilities { openFileExternally?: (intent: ExperimentalFileOpenOptions) => boolean; openFilePreview?: (intent: AppFilePreviewIntent) => boolean; + openFixedTab?: (intent: AppFixedTabOpenIntent) => boolean; openUrl?: (intent: AppUrlOpenIntent) => boolean; } interface ResolvedAppNavigationHostCapabilities { - openFileExternally: ((intent: ExperimentalFileOpenOptions) => boolean) | null; - openFilePreview: ((intent: AppFilePreviewIntent) => boolean) | null; - openUrl: ((intent: AppUrlOpenIntent) => boolean) | null; + openFileExternally: (intent: ExperimentalFileOpenOptions) => boolean; + openFilePreview: (intent: AppFilePreviewIntent) => boolean; + openFixedTab: (intent: AppFixedTabOpenIntent) => boolean; + openUrl: (intent: AppUrlOpenIntent) => boolean; } +const rejectNavigationIntent = () => false; +const DEFAULT_APP_NAVIGATION_HOST: ResolvedAppNavigationHostCapabilities = { + openFileExternally: rejectNavigationIntent, + openFilePreview: rejectNavigationIntent, + openFixedTab: rejectNavigationIntent, + openUrl: rejectNavigationIntent, +}; + const AppNavigationHostContext = - createContext(null); + createContext( + DEFAULT_APP_NAVIGATION_HOST, + ); /** * Adds the navigation capabilities owned by one app surface. Providers compose: @@ -47,18 +74,21 @@ export function AppNavigationHostProvider({ const value = useMemo( () => ({ openFileExternally: - capabilities.openFileExternally ?? parent?.openFileExternally ?? null, + capabilities.openFileExternally ?? parent.openFileExternally, openFilePreview: - capabilities.openFilePreview ?? parent?.openFilePreview ?? null, - openUrl: capabilities.openUrl ?? parent?.openUrl ?? null, + capabilities.openFilePreview ?? parent.openFilePreview, + openFixedTab: capabilities.openFixedTab ?? parent.openFixedTab, + openUrl: capabilities.openUrl ?? parent.openUrl, }), [ capabilities.openFileExternally, capabilities.openFilePreview, + capabilities.openFixedTab, capabilities.openUrl, - parent?.openFileExternally, - parent?.openFilePreview, - parent?.openUrl, + parent.openFileExternally, + parent.openFilePreview, + parent.openFixedTab, + parent.openUrl, ], ); return ( @@ -70,23 +100,5 @@ export function AppNavigationHostProvider({ /** Semantic navigation intents accepted by the current app surface. */ export function useAppNavigationHost() { - const host = useContext(AppNavigationHostContext); - const openFileExternally = useCallback( - (intent: ExperimentalFileOpenOptions): boolean => - host?.openFileExternally?.(intent) ?? false, - [host?.openFileExternally], - ); - const openFilePreview = useCallback( - (intent: AppFilePreviewIntent): boolean => - host?.openFilePreview?.(intent) ?? false, - [host?.openFilePreview], - ); - const openUrl = useCallback( - (intent: AppUrlOpenIntent): boolean => host?.openUrl?.(intent) ?? false, - [host?.openUrl], - ); - return useMemo( - () => ({ openFileExternally, openFilePreview, openUrl }), - [openFileExternally, openFilePreview, openUrl], - ); + return useContext(AppNavigationHostContext); } diff --git a/apps/app/src/lib/plugin-sdk-app-impl.tsx b/apps/app/src/lib/plugin-sdk-app-impl.tsx index 971839beb6..c6de613bbc 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.tsx @@ -23,6 +23,8 @@ import { useRealtimeConnectionState, useRpc, useSettings, + experimental_useAppPanel, + experimental_useFixedTabTarget, } from "./plugin-sdk-hooks"; import { useSidebarThreadActions, @@ -51,6 +53,8 @@ export const pluginSdkAppImplementation = { definePluginApp, useBbContext, useBbNavigate, + experimental_useAppPanel, + experimental_useFixedTabTarget, useComposer, useComposerView, useRealtime, diff --git a/apps/app/src/lib/plugin-sdk-hooks.ts b/apps/app/src/lib/plugin-sdk-hooks.ts index e6d8a92533..4548b7a337 100644 --- a/apps/app/src/lib/plugin-sdk-hooks.ts +++ b/apps/app/src/lib/plugin-sdk-hooks.ts @@ -19,7 +19,12 @@ import type { PluginRpcContract, PluginRpcClient, PluginSettingsState, + ExperimentalAppPanel, + ExperimentalFixedTabTargetDelivery, + ExperimentalPluginFixedTabReference, + JsonValue, } from "@get-bb/plugin-sdk"; +import { jsonValueSchema } from "@bb/domain"; import { PluginSlotOwnershipContext, usePluginId, @@ -54,6 +59,10 @@ import { wsManager } from "@/lib/ws"; import { pluginSdkSettingsQueryKey } from "@/hooks/queries/query-keys"; import { useAppNavigationHost } from "@/lib/app-navigation-host"; import { normalizeExperimentalFileOpenOptions } from "@/lib/live-file-navigation"; +import { + getPluginFixedTabOwnerId, + useAppFixedTabTarget, +} from "@/lib/app-fixed-tab-navigation"; /** * Host implementations of the `@get-bb/plugin-sdk/app` hooks (plugin design @@ -385,6 +394,53 @@ export function useBbNavigate(): BbNavigate { ); } +export function experimental_useAppPanel(): ExperimentalAppPanel { + const pluginId = usePluginId(); + const appNavigation = useAppNavigationHost(); + const openFixedTab = useCallback( + (options) => { + const targetResult = + options.target === undefined + ? null + : jsonValueSchema.safeParse(options.target); + if (targetResult !== null && !targetResult.success) return false; + return appNavigation.openFixedTab({ + surface: options.surface, + tab: { + ownerId: getPluginFixedTabOwnerId(pluginId, options.tab.panelId), + tabId: options.tab.id, + }, + ...(targetResult?.success === true + ? { target: targetResult.data } + : {}), + }); + }, + [appNavigation, pluginId], + ); + return useMemo(() => ({ openFixedTab }), [openFixedTab]); +} + +export function experimental_useFixedTabTarget( + tab: ExperimentalPluginFixedTabReference, +): ExperimentalFixedTabTargetDelivery | null { + const pluginId = usePluginId(); + const delivery = useAppFixedTabTarget( + getPluginFixedTabOwnerId(pluginId, tab.panelId), + tab.id, + ); + if (delivery === null || tab.experimental_target === undefined) return null; + try { + if (!tab.experimental_target.validate(delivery.target)) return null; + } catch { + return null; + } + return { + consume: delivery.consume, + sequence: delivery.sequence, + target: delivery.target, + }; +} + function reconcileComposerMentions( currentText: string, nextText: string, diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 8b3f444a3a..334f591327 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -141,7 +141,9 @@ import { UrlOpenRoutingProvider } from "@/lib/url-open-routing"; import { AppNavigationHostProvider, type AppFilePreviewIntent, + type AppFixedTabOpenIntent, } from "@/lib/app-navigation-host"; +import { openAppFixedTabFromDestinations } from "@/lib/app-fixed-tab-navigation"; import { normalizeExperimentalFileOpenOptions, toFilePreviewLineRange, @@ -1381,7 +1383,11 @@ function RootComposeSurface({ ], ); const appNavigationCapabilities = useMemo( - () => ({ openFilePreview: handleOpenLiveFilePreview }), + () => ({ + openFilePreview: handleOpenLiveFilePreview, + openFixedTab: (intent: AppFixedTabOpenIntent) => + openAppFixedTabFromDestinations([], intent), + }), [handleOpenLiveFilePreview], ); // Click handler for inserted mention pills in the root composer: threads diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 95cb639880..6cb17b4844 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -131,6 +131,14 @@ import { type ThreadRoutePathArgs, } from "@/lib/route-paths"; import { useGitDiffPanel } from "@/components/secondary-panel/git-diff/useGitDiffPanel"; +import { + createGitDiffFixedTabDestination, + GIT_DIFF_FIXED_TAB_REFERENCE, +} from "@/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation"; +import { + createThreadInfoFixedTabDestination, + THREAD_INFO_FIXED_TAB_REFERENCE, +} from "@/components/secondary-panel/thread-info-fixed-tab-navigation"; import { ThreadDetailHeader } from "./ThreadDetailHeader"; import { ThreadDetailPromptArea, @@ -199,7 +207,9 @@ import { import { AppNavigationHostProvider, type AppFilePreviewIntent, + type AppFixedTabOpenIntent, } from "@/lib/app-navigation-host"; +import { openAppFixedTabFromDestinations } from "@/lib/app-fixed-tab-navigation"; import { normalizeExperimentalFileOpenOptions, toFilePreviewLineRange, @@ -1287,12 +1297,12 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { }); const { closePanel: closeSecondaryPanel, - openCommitDiff: openSecondaryPanelCommitDiff, + openCommitDiff: openGitDiffCommitDestination, openCompactDrawer, - openDiffFile: openSecondaryPanelDiffFile, - openDiffPanel: openSecondaryPanelDiffPanel, + openDiffFile: openGitDiffFileDestination, + openDiffPanel: openGitDiffDestination, openHostFile, - openPanel: openSecondaryPanel, + openPanel: openFixedViewDestination, openStorageFile, openWorkspaceFile, togglePanel: toggleSecondaryPanel, @@ -1310,6 +1320,68 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { openPersistedWorkspaceFile, togglePersistedPanel: toggleDefaultPersistedSecondaryPanel, }); + const fixedTabDestinations = useMemo( + () => [ + createThreadInfoFixedTabDestination(() => + openFixedViewDestination("thread-info"), + ), + createGitDiffFixedTabDestination({ + eligible: canUseGitUi, + openCommit: openGitDiffCommitDestination, + openFile: openGitDiffFileDestination, + openOrdinary: openGitDiffDestination, + }), + ], + [ + canUseGitUi, + openFixedViewDestination, + openGitDiffCommitDestination, + openGitDiffDestination, + openGitDiffFileDestination, + ], + ); + const openFixedTab = useCallback( + (intent: AppFixedTabOpenIntent): boolean => + openAppFixedTabFromDestinations(fixedTabDestinations, intent), + [fixedTabDestinations], + ); + const openSecondaryPanel = useCallback( + (panel: ThreadSecondaryPanelTab) => + openFixedTab({ + surface: { kind: "current" }, + tab: + panel === "git-diff" + ? GIT_DIFF_FIXED_TAB_REFERENCE + : THREAD_INFO_FIXED_TAB_REFERENCE, + }), + [openFixedTab], + ); + const openSecondaryPanelDiffPanel = useCallback( + () => + openFixedTab({ + surface: { kind: "current" }, + tab: GIT_DIFF_FIXED_TAB_REFERENCE, + }), + [openFixedTab], + ); + const openSecondaryPanelDiffFile = useCallback( + (path: string) => + openFixedTab({ + surface: { kind: "current" }, + tab: GIT_DIFF_FIXED_TAB_REFERENCE, + target: { kind: "file", path }, + }), + [openFixedTab], + ); + const openSecondaryPanelCommitDiff = useCallback( + (sha: string) => + openFixedTab({ + surface: { kind: "current" }, + tab: GIT_DIFF_FIXED_TAB_REFERENCE, + target: { kind: "commit", sha }, + }), + [openFixedTab], + ); const handleOpenLiveFilePreview = useCallback( (intent: AppFilePreviewIntent): boolean => { const normalized = normalizeExperimentalFileOpenOptions(intent); @@ -1351,8 +1423,8 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { ], ); const appNavigationCapabilities = useMemo( - () => ({ openFilePreview: handleOpenLiveFilePreview }), - [handleOpenLiveFilePreview], + () => ({ openFilePreview: handleOpenLiveFilePreview, openFixedTab }), + [handleOpenLiveFilePreview, openFixedTab], ); const handleOpenTimelinePluginPanel = useCallback( diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index b7434ac203..126b8d4712 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -1602,7 +1602,9 @@ Slot props contracts (versioned, additive-only): tab survived. `experimental_fixedTabs` declares ordered, non-closable page views in that - same host tab strip: `{ id, title, icon, component, layout? }`. BB opens the + same host tab strip: + `{ id, panelId?, title, icon, component, layout?, experimental_target? }`. + BB opens the first fixed tab on the page's first wide-layout visit, but remembers a later user close. Only the active fixed-tab component is mounted, and closing the panel unmounts it. It receives the same `{ subPath }` as the main page. `layout: "padded"` (the default) gives it @@ -1611,6 +1613,21 @@ Slot props contracts (versioned, additive-only): do not replace its native chrome, Browser, Terminal, or keyboard commands. Experimental: see `docs/api_to_audit.md`. + A registration whose `panelId` exactly matches its containing nav panel's + `id` is also the stable reference for selecting that plugin-owned tab. + Existing untargeted declarations may omit `panelId`; add it whenever code + will address the tab. A targetable tab declares + `experimental_target: { validate(value): value is Target }`; BB checks JSON + safety before calling the owner validator. From any component of the same + plugin on that page, call + `experimental_useAppPanel().openFixedTab({ surface: { kind: "current" }, tab, +target? })`. Inside the fixed-tab component, + `experimental_useFixedTabTarget(tab)` returns `{ sequence, target, consume }` + after validation. Apply the target and call `consume()` so it cannot replay + on a later remount. Selection persists through the host's ordinary panel + state; target delivery is memory-only. Invalid, unavailable, untargeted, or + other-plugin references return false without changing valid panel state. + `experimental_sidebarAccessory` is a no-props, presentational component at the trailing edge of the sidebar row. It can own SDK hooks for a live count or short status without lifting state into the host sidebar. The host does @@ -1886,6 +1903,16 @@ className?, leadingContent?, messageActions? }` — an environment id or turn a project id into a workspace target. The testing harness records both calls in `navigateCalls` and gates them with the `openFilePreview` / `openFileExternally` behavior options. +- `experimental_useAppPanel` — returns the generic current-surface fixed-tab + controller. `openFixedTab({ surface: { kind: "current" }, tab, target? })` + accepts a plugin's own eligible fixed-tab registration, validates any target + through that registration's `experimental_target` contract, opens the shared + panel, and returns host acceptance. The controller does not interpret target + shapes. Targeted fixed tabs use `experimental_useFixedTabTarget(tab)` and + call delivery `consume()` after applying the target. The frontend harness + records accepted calls in `experimental_fixedTabOpenCalls`, gates them with + `experimental_openFixedTab`, and seeds delivery with + `experimental_fixedTabTarget`. - `experimental_NewThreadComposer` — bb's complete compose surface for CREATING a thread (the create-side counterpart to `ThreadChat`): prompt editor with @-mentions and expand, `+` attachments, diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 0b069d7fd1..2452af8b2c 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -175,7 +175,7 @@ unexpected-exit recovery without feature-specific core hooks. limits without pretending to model process startup, crashes, native watcher recovery, or reconnect behavior. -## `PluginNavPanelRegistration.experimental_fixedTabs` +## Fixed-tab navigation (`PluginNavPanelRegistration.experimental_fixedTabs`, `experimental_target`, `experimental_useAppPanel`, and `experimental_useFixedTabTarget`) **What it does.** Lets a nav panel declare ordered, non-closable tabs in the host-owned right panel. The host owns tab selection, persistence, chrome, @@ -183,7 +183,26 @@ Browser and Terminal tools, and only mounts the active plugin component while the panel is open. A fixed tab receives the nav page's current `subPath`; `layout: "padded"` uses host padding and scrolling, while `layout: "flush"` gives the component the whole content region. On the first visit the first declared fixed tab opens on -wide layouts. A later user close remains closed. +wide layouts. A later user close remains closed. A fixed-tab registration is +also its stable, plugin-owner-and-panel-scoped reference when its `panelId` +matches the containing nav panel's id. `experimental_useAppPanel()` +can select one of the calling plugin's eligible tabs on the current surface +and optionally submit a JSON-safe target. The tab's `experimental_target` +validator owns the target type and policy; `experimental_useFixedTabTarget()` +delivers the validated value with a sequence and explicit `consume()`. Tab +selection stays durable while delivery is memory-only. Core Changes targets +and plugin targets resolve through the same feature-agnostic controller. + +**Public surface.** `ExperimentalFixedTabTargetContract`, +`ExperimentalPluginFixedTabReference`, +`ExperimentalPluginFixedTabRegistration`, +`ExperimentalPluginFixedTabDeclaration`, `ExperimentalAppPanelSurface`, +`ExperimentalFixedTabTargetDelivery`, `ExperimentalOpenFixedTabOptions`, +`ExperimentalAppPanel`, `experimental_useAppPanel`, and +`experimental_useFixedTabTarget`. The frontend testing runtime mirrors this +with `ExperimentalFixedTabOpenCall`, the +`experimental_openFixedTab`/`experimental_fixedTabTarget` render options, and +the `experimental_fixedTabOpenCalls` inspection list. **Audit before stabilizing.** @@ -198,6 +217,17 @@ wide layouts. A later user close remains closed. and nested scrolling before freezing the presentation contract. 5. Confirm named icon hints and the non-closable tab treatment remain the right amount of plugin-controlled chrome. +6. Audit registration objects as references: identity is scoped to the mounted + plugin and current nav panel, with no cross-plugin addressing or global ids. +7. Confirm sync type guards remain the right owner validation contract and + define error reporting if a validator throws or becomes stale after reload. +8. Exercise repeated equal targets, explicit consumption, crashes, close and + remount, refresh, and compact drawer animation; targets must never persist + or replay after consumption. +9. Decide whether a future cross-thread surface should navigate before opening; + the initial public surface intentionally supports only `{ kind: "current" }`. +10. Keep core and plugin destinations on the same resolver and verify the + controller never learns Changes, file, task, or document target shapes. ## `PluginNavPanelRegistration.experimental_sidebarAccessory` diff --git a/examples/plugins/thread-chat-demo/README.md b/examples/plugins/thread-chat-demo/README.md index a79252b3cf..833a1e4cae 100644 --- a/examples/plugins/thread-chat-demo/README.md +++ b/examples/plugins/thread-chat-demo/README.md @@ -6,6 +6,12 @@ Demonstrates the SDK's host-owned `ThreadChat` component and the - **Nav panel "ThreadChat demo"** — enter any thread id and the panel renders that thread's full chat (``). The "Focus composer" button exercises `focusRequest`. +- **Targeted fixed tab "Compact thread"** — the nav-page button calls the + generic `experimental_useAppPanel().openFixedTab(...)` primitive with the + page-owned registration and a typed thread target. The tab validates, + consumes, and renders that transient target without putting it in the URL or + persisted panel state. "View source" also demonstrates imperative URL + opening through BB's preference router. - **Message action "Open in demo panel"** — appears on every chat message's action bar and in the assistant-message text-selection menu. It opens this plugin's own thread panel via `context.openPanel({ actionId, params })`, diff --git a/examples/plugins/thread-chat-demo/app.tsx b/examples/plugins/thread-chat-demo/app.tsx index b14f8ec358..902c5df31d 100644 --- a/examples/plugins/thread-chat-demo/app.tsx +++ b/examples/plugins/thread-chat-demo/app.tsx @@ -9,18 +9,39 @@ // - `messageAction`, host-rendered chrome on every chat message (and the // text-selection menu): "Open in demo panel" opens this plugin's own // thread panel anchored on the clicked message via `openPanel`. -import { useState } from "react"; +import { useEffect, useState } from "react"; import { definePluginApp, + experimental_useAppPanel, + experimental_useFixedTabTarget, ThreadChat, useBbContext, + useBbNavigate, + type ExperimentalPluginFixedTabRegistration, + type JsonValue, } from "@get-bb/plugin-sdk/app"; +type DemoThreadTarget = { kind: "thread"; threadId: string }; + +function isDemoThreadTarget(value: JsonValue): value is DemoThreadTarget { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.keys(value).length === 2 && + value.kind === "thread" && + typeof value.threadId === "string" && + value.threadId.length > 0 + ); +} + function ThreadChatDemoPanel({ subPath }: { subPath: string }) { const { threadId: routeThreadId } = useBbContext(); const [threadId, setThreadId] = useState(subPath); const [focusRequest, setFocusRequest] = useState(0); const activeThreadId = threadId || routeThreadId || ""; + const panel = experimental_useAppPanel(); + const navigate = useBbNavigate(); return (
@@ -37,6 +58,31 @@ function ThreadChatDemoPanel({ subPath }: { subPath: string }) { > Focus composer + +
{activeThreadId ? ( (null); + useEffect(() => { + if (delivery === null) return; + setThreadId(delivery.target.threadId); + delivery.consume(); + }, [delivery]); + return threadId === null ? ( +

+ Choose “Open compact tab” from the demo page. +

+ ) : ( + + ); +} + +const demoThreadFixedTab = { + panelId: "thread-chat-demo", + id: "compact-thread", + title: "Compact thread", + icon: "PanelRight", + component: DemoThreadFixedTab, + layout: "flush", + experimental_target: { validate: isDemoThreadTarget }, +} satisfies ExperimentalPluginFixedTabRegistration; + interface DemoPanelParams { anchorText?: string; selectedText?: string; @@ -103,6 +176,7 @@ export default definePluginApp((app) => { icon: "MessageSquarePlus", path: "thread-chat", component: ThreadChatDemoPanel, + experimental_fixedTabs: [demoThreadFixedTab], }); app.slots.threadPanelAction({ id: "demo-panel", diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index b0858f292b..ee12e6147d 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -38,6 +38,18 @@ the current host accepted the intent. Targets never infer an ambient workspace. The frontend harness records both methods and accepts `openFilePreview` and `openFileExternally` behavior options. +A nav panel's `experimental_fixedTabs` entries are also stable references to +that plugin's own tabs when their `panelId` matches the containing nav panel's +`id`. Give a targeted tab an `experimental_target.validate` type guard, call +`experimental_useAppPanel().openFixedTab({ surface: { kind: +"current" }, tab, target })`, and read the in-memory delivery inside the tab +with `experimental_useFixedTabTarget(tab)`. Call `consume()` after applying it. +The host validates JSON before the owner's type guard, persists only selection, +and returns false for an unavailable tab or invalid target. The frontend +harness records accepted requests in `experimental_fixedTabOpenCalls`, accepts +an `experimental_openFixedTab` behavior, and can seed +`experimental_fixedTabTarget` delivery. + Every panel-open entry point reports the same way: `openThreadPanel` and the `openPanel` handed to `threadPanelAction`, `experimental_newThreadPanelAction`, and `messageAction` `run` callbacks all return `boolean` — true when the host diff --git a/packages/plugin-sdk/src/__tests__/fixed-tab-types.test.ts b/packages/plugin-sdk/src/__tests__/fixed-tab-types.test.ts new file mode 100644 index 0000000000..baa4b9739d --- /dev/null +++ b/packages/plugin-sdk/src/__tests__/fixed-tab-types.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { + ExperimentalAppPanel, + ExperimentalPluginFixedTabReference, + JsonValue, +} from "../app.js"; + +type RecordTarget = { kind: "record"; recordId: string }; + +const untargetedTab = { + panelId: "tasks", + id: "navigation", +} satisfies ExperimentalPluginFixedTabReference; + +const targetedTab = { + panelId: "tasks", + id: "details", + experimental_target: { + validate(value: JsonValue): value is RecordTarget { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + value.kind === "record" && + typeof value.recordId === "string" + ); + }, + }, +} satisfies ExperimentalPluginFixedTabReference; + +declare const panel: ExperimentalAppPanel; +if (false) { + panel.openFixedTab({ + surface: { kind: "current" }, + tab: untargetedTab, + }); + panel.openFixedTab({ + surface: { kind: "current" }, + tab: targetedTab, + target: { kind: "record", recordId: "issue-42" }, + }); + panel.openFixedTab({ + surface: { kind: "current" }, + tab: untargetedTab, + // @ts-expect-error Untargeted tabs reject targets at compile time. + target: { kind: "record", recordId: "issue-42" }, + }); + panel.openFixedTab({ + surface: { kind: "current" }, + tab: targetedTab, + // @ts-expect-error The owner-defined target shape is retained by the ref. + target: { kind: "record", recordId: 42 }, + }); +} + +describe("fixed-tab public types", () => { + it("retain stable owner-declared ids at runtime", () => { + expect([untargetedTab.id, targetedTab.id]).toEqual([ + "navigation", + "details", + ]); + }); +}); diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index e41b426e70..9a318a8ab1 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -351,6 +351,59 @@ export interface PluginSettingsSectionRegistration { component: ComponentType; } +/** + * Owner-defined validator for a fixed tab's transient target. The host first + * verifies that the value is JSON-safe, then calls this validator before + * selecting the tab or delivering the target. + */ +export interface ExperimentalFixedTabTargetContract { + validate(value: JsonValue): value is Target; +} + +/** Stable, owner-scoped reference used by the app-panel controller. */ +export type ExperimentalPluginFixedTabReference< + Target extends JsonValue = never, +> = { + /** The owning `navPanel` id; validated against the containing registration. */ + readonly panelId: string; + /** Unique within the owning nav panel; letters, digits, `-`, `_`. */ + readonly id: string; +} & ([Target] extends [never] + ? { + /** An untargeted tab cannot be opened with a target. */ + readonly experimental_target?: never; + } + : { + /** Owner validation required before the host delivers a target. */ + readonly experimental_target: ExperimentalFixedTabTargetContract; + }); + +/** A fixed tab declared by a plugin nav panel. */ +export type ExperimentalPluginFixedTabRegistration< + Target extends JsonValue = never, +> = ExperimentalPluginFixedTabReference & { + title: string; + /** Icon hint (BB icon name); unknown names fall back to a generic icon. */ + icon: string; + component: ComponentType; + /** `flush` lets the component own padding and scrolling. */ + layout?: "padded" | "flush"; +}; + +interface LegacyPluginFixedTabRegistration { + id: string; + title: string; + icon: string; + component: ComponentType; + layout?: "padded" | "flush"; + panelId?: string; + experimental_target?: never; +} + +export type ExperimentalPluginFixedTabDeclaration = + | LegacyPluginFixedTabRegistration + | ExperimentalPluginFixedTabRegistration; + export interface PluginNavPanelRegistration { /** Unique within the plugin; letters, digits, `-`, `_`. */ id: string; @@ -369,16 +422,7 @@ export interface PluginNavPanelRegistration { * * Experimental: see docs/api_to_audit.md. */ - experimental_fixedTabs?: readonly { - /** Unique within this nav panel; letters, digits, `-`, `_`. */ - id: string; - title: string; - /** Icon hint (BB icon name); unknown names fall back to a generic icon. */ - icon: string; - component: ComponentType; - /** `flush` lets the component own padding and scrolling. */ - layout?: "padded" | "flush"; - }[]; + experimental_fixedTabs?: readonly ExperimentalPluginFixedTabDeclaration[]; /** * Optional presentational component rendered at the trailing edge of this * panel's sidebar row. It receives no props so it can own a narrow live @@ -1597,6 +1641,33 @@ export interface ExperimentalFileLinkProps extends Omit< location?: ExperimentalFileLocation | null; } +/** The panel surface resolved by the component making the request. */ +export type ExperimentalAppPanelSurface = { kind: "current" }; + +/** + * A transient target delivered to its owning fixed tab. Calling `consume` + * prevents the target from replaying if the tab remounts later. + */ +export interface ExperimentalFixedTabTargetDelivery { + readonly sequence: number; + readonly target: Target; + consume(): void; +} + +export type ExperimentalOpenFixedTabOptions = { + surface: ExperimentalAppPanelSurface; + tab: ExperimentalPluginFixedTabReference; + /** Omit to select the tab without delivering a transient target. */ + target?: NoInfer; +}; + +/** Surface-aware controller for selecting owner-scoped fixed tabs. */ +export interface ExperimentalAppPanel { + openFixedTab( + options: ExperimentalOpenFixedTabOptions, + ): boolean; +} + /** Current app selection, derived from the route. */ export interface BbContext { projectId: string | null; @@ -1679,6 +1750,12 @@ export interface PluginSdkApp { useSettings(): PluginSettingsState; useBbContext(): BbContext; useBbNavigate(): BbNavigate; + /** Select one of this plugin's eligible fixed tabs on the current surface. */ + experimental_useAppPanel(): ExperimentalAppPanel; + /** Read and consume a validated transient target inside its owning tab. */ + experimental_useFixedTabTarget( + tab: ExperimentalPluginFixedTabReference, + ): ExperimentalFixedTabTargetDelivery | null; useComposer(): PluginComposerApi; /** * The sidebar's live thread view (see {@link PluginSidebarThreadsState}). diff --git a/packages/plugin-sdk/src/app.ts b/packages/plugin-sdk/src/app.ts index a4b32254e5..752af2ec1a 100644 --- a/packages/plugin-sdk/src/app.ts +++ b/packages/plugin-sdk/src/app.ts @@ -61,6 +61,9 @@ export const useRealtimeConnectionState = runtime.useRealtimeConnectionState; export const useSettings = runtime.useSettings; export const useBbContext = runtime.useBbContext; export const useBbNavigate = runtime.useBbNavigate; +export const experimental_useAppPanel = runtime.experimental_useAppPanel; +export const experimental_useFixedTabTarget = + runtime.experimental_useFixedTabTarget; export const useComposer = runtime.useComposer; export const useComposerView = runtime.useComposerView; // Sidebar surfaces for plugins that replace the thread list (experimental — diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts index d374780aec..55ddd4a7e9 100644 --- a/packages/plugin-sdk/src/internal/plugin-app-collector.ts +++ b/packages/plugin-sdk/src/internal/plugin-app-collector.ts @@ -138,6 +138,7 @@ export function collectPluginAppRegistrations( const kind = "slots.navPanel"; const id = requireSlotId(kind, registration?.id); requireUniqueId(kind, seenIds.navPanel, id); + const panelId = id; const path = requireNonEmptyString(kind, "path", registration.path); if (!PLUGIN_SLOT_ID_PATTERN.test(path)) { throw new Error( @@ -184,8 +185,29 @@ export function collectPluginAppRegistrations( `${fixedTabKind}: "layout" must be "padded" or "flush" when set`, ); } + if ( + fixedTab?.panelId !== undefined && + fixedTab.panelId !== panelId + ) { + throw new Error( + `${fixedTabKind}: "panelId" must match its containing navPanel id ${JSON.stringify(panelId)}`, + ); + } + const experimentalTarget = fixedTab?.experimental_target; + if ( + experimentalTarget !== undefined && + (typeof experimentalTarget !== "object" || + experimentalTarget === null || + typeof Reflect.get(experimentalTarget, "validate") !== + "function") + ) { + throw new Error( + `${fixedTabKind}: "experimental_target.validate" must be a function when set`, + ); + } return { id, + panelId, title: requireNonEmptyString( fixedTabKind, "title", @@ -200,6 +222,12 @@ export function collectPluginAppRegistrations( PluginNavPanelFixedTabRegistration["component"] >(fixedTabKind, fixedTab?.component), ...(layout === undefined ? {} : { layout }), + ...(experimentalTarget === undefined + ? {} + : { + experimental_target: + experimentalTarget as PluginNavPanelFixedTabRegistration["experimental_target"], + }), }; }); })(); diff --git a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx index 13da601dd6..7f95aad0d4 100644 --- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx +++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx @@ -8,6 +8,7 @@ import type { PluginComposerScope, PluginMessageDirectiveProps, PluginNavPanelProps, + ExperimentalPluginFixedTabReference, } from "../../app-contract.js"; import { installTestPluginRuntime, @@ -24,6 +25,8 @@ const { definePluginApp, experimental_FileLink: FileLink, experimental_UrlLink: UrlLink, + experimental_useAppPanel, + experimental_useFixedTabTarget, ThreadChat, useBbNavigate, useComposer, @@ -33,6 +36,53 @@ const { useRpc, } = await import("../../app.js"); +type TestTaskTarget = { + kind: "task"; + taskId: string; +}; + +const taskDetailsTab = { + panelId: "tasks", + id: "details", + experimental_target: { + validate(value): value is TestTaskTarget { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + value.kind === "task" && + typeof value.taskId === "string" + ); + }, + }, +} satisfies ExperimentalPluginFixedTabReference; + +function FixedTabProbe() { + const panel = experimental_useAppPanel(); + const delivery = experimental_useFixedTabTarget(taskDetailsTab); + return ( +
+ + {delivery === null ? null : ( + + )} +
+ ); +} + const typedRpcContract = defineRpcContract({ getItem: { input: z.object({ id: z.string() }), @@ -633,6 +683,19 @@ describe("loadPluginApp", () => { function Navigation({ subPath }: PluginNavPanelProps) { return {subPath}; } + const targetContract = { + validate( + value: import("../../json-value.js").JsonValue, + ): value is TestTaskTarget { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + value.kind === "task" && + typeof value.taskId === "string" + ); + }, + }; const captured = await loadPluginApp( definePluginApp((builder) => { builder.slots.navPanel({ @@ -643,11 +706,13 @@ describe("loadPluginApp", () => { component: Panel, experimental_fixedTabs: [ { + panelId: "tasks", id: "navigation", title: "Navigation", icon: "PanelRight", component: Navigation, layout: "flush", + experimental_target: targetContract, }, ], }); @@ -656,15 +721,75 @@ describe("loadPluginApp", () => { expect(captured.navPanels[0]?.experimental_fixedTabs).toEqual([ { + panelId: "tasks", id: "navigation", title: "Navigation", icon: "PanelRight", component: Navigation, layout: "flush", + experimental_target: targetContract, }, ]); }); + it("rejects a malformed fixed-tab target contract", async () => { + await expect( + loadPluginApp( + definePluginApp((builder) => { + builder.slots.navPanel({ + id: "tasks", + title: "Tasks", + icon: "ListTodo", + path: "tasks", + component: Panel, + experimental_fixedTabs: [ + { + panelId: "tasks", + id: "details", + title: "Details", + icon: "Info", + component: Panel, + experimental_target: { + // @ts-expect-error Runtime collector coverage for malformed JS. + validate: "not-a-function", + }, + }, + ], + }); + }), + ), + ).rejects.toThrow( + '"experimental_target.validate" must be a function when set', + ); + }); + + it("rejects a fixed-tab reference scoped to a different nav panel", async () => { + await expect( + loadPluginApp( + definePluginApp((builder) => { + builder.slots.navPanel({ + id: "tasks", + title: "Tasks", + icon: "ListTodo", + path: "tasks", + component: Panel, + experimental_fixedTabs: [ + { + panelId: "other-page", + id: "navigation", + title: "Navigation", + icon: "PanelRight", + component: Panel, + }, + ], + }); + }), + ), + ).rejects.toThrow( + '"panelId" must match its containing navPanel id "tasks"', + ); + }); + it("rejects duplicate nav panel fixed tab ids", async () => { await expect( loadPluginApp( @@ -1017,6 +1142,35 @@ describe("renderSlot", () => { ]); }); + it("records validated fixed-tab opens and exposes consumable transient delivery", () => { + const slot = renderSlot( + { component: FixedTabProbe }, + {}, + { + experimental_openFixedTab: () => true, + experimental_fixedTabTarget: { + panelId: "tasks", + tabId: "details", + target: { kind: "task", taskId: "TASK-7" }, + }, + }, + ); + + const consume = slot.getByRole("button", { name: "Consume TASK-7" }); + fireEvent.click(consume); + expect(slot.queryByRole("button", { name: "Consume TASK-7" })).toBeNull(); + + fireEvent.click(slot.getByRole("button", { name: "Open details" })); + expect(slot.inspection.experimental_fixedTabOpenCalls).toEqual([ + { + surface: { kind: "current" }, + panelId: "tasks", + tabId: "details", + target: { kind: "task", taskId: "TASK-42" }, + }, + ]); + }); + it("drives the shared realtime connection lifecycle", async () => { const slot = renderSlot( app.homepageSections[0]!, diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index 7d9ed89c18..fd23acd685 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -57,6 +57,10 @@ import { type ExperimentalUrlLinkProps, type ExperimentalFileLinkProps, type ExperimentalFileOpenOptions, + type ExperimentalAppPanel, + type ExperimentalFixedTabTargetDelivery, + type ExperimentalOpenFixedTabOptions, + type ExperimentalPluginFixedTabReference, type NewThreadComposerProps, type ThreadChatProps, type DiffProps, @@ -126,6 +130,13 @@ export type NavigateCall = options: ExperimentalFileOpenOptions; }; +export interface ExperimentalFixedTabOpenCall { + surface: ExperimentalOpenFixedTabOptions["surface"]; + panelId: string; + tabId: string; + target?: JsonValue; +} + export interface ComposerLog { /** Latest plain text in this isolated composer scope. */ readonly text: string; @@ -162,6 +173,9 @@ interface SlotEnv { bbContext: BbContext; navigate: BbNavigate; navigateCalls: NavigateCall[]; + appPanel: ExperimentalAppPanel; + experimental_fixedTabOpenCalls: ExperimentalFixedTabOpenCall[]; + fixedTabTarget: TestFixedTabTargetStore; composer: TestComposerStore; composerLog: ComposerLog; sidebarThreads: PluginSidebarThreadsState; @@ -170,6 +184,17 @@ interface SlotEnv { sidebarPullRequests: ReadonlyMap; } +interface TestFixedTabTargetStore { + consume(sequence: number): void; + getSnapshot(): { + panelId: string; + sequence: number; + tabId: string; + target: JsonValue; + } | null; + subscribe(listener: () => void): () => void; +} + /** One recorded `experimental_useSidebarThreadActions()` call. */ export interface SidebarActionCall { method: keyof PluginSidebarThreadActions; @@ -545,6 +570,37 @@ const testPluginSdkApp = { useBbNavigate(): BbNavigate { return useSlotEnv("useBbNavigate").navigate; }, + experimental_useAppPanel(): ExperimentalAppPanel { + return useSlotEnv("experimental_useAppPanel").appPanel; + }, + experimental_useFixedTabTarget( + tab: ExperimentalPluginFixedTabReference, + ): ExperimentalFixedTabTargetDelivery | null { + const store = useSlotEnv("experimental_useFixedTabTarget").fixedTabTarget; + const delivery = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); + if ( + delivery === null || + delivery.panelId !== tab.panelId || + delivery.tabId !== tab.id || + tab.experimental_target === undefined + ) { + return null; + } + try { + if (!tab.experimental_target.validate(delivery.target)) return null; + } catch { + return null; + } + return { + consume: () => store.consume(delivery.sequence), + sequence: delivery.sequence, + target: delivery.target, + }; + }, useComposer(): PluginComposerApi { const composer = useSlotEnv("useComposer").composer; const version = useSyncExternalStore( @@ -886,6 +942,14 @@ export interface RenderSlotOptions< openFilePreview?: (options: ExperimentalFileOpenOptions) => boolean; /** Host acceptance for preferred-external file intents. */ openFileExternally?: (options: ExperimentalFileOpenOptions) => boolean; + /** Host acceptance for an owner-scoped fixed-tab selection. */ + experimental_openFixedTab?: (call: ExperimentalFixedTabOpenCall) => boolean; + /** Initial transient delivery visible to `experimental_useFixedTabTarget`. */ + experimental_fixedTabTarget?: { + panelId: string; + tabId: string; + target: JsonValue; + }; } /** Host-originated inputs a slot test can drive deterministically. */ @@ -911,6 +975,8 @@ export interface RenderedSlotInspectionState { readonly rpcCalls: RpcCall[]; /** Every `useBbNavigate()` call, in order. */ readonly navigateCalls: NavigateCall[]; + /** Every validated `experimental_useAppPanel().openFixedTab` call. */ + readonly experimental_fixedTabOpenCalls: ExperimentalFixedTabOpenCall[]; /** Every `experimental_useSidebarThreadActions()` call, in order. */ readonly sidebarActionCalls: SidebarActionCall[]; /** Everything written through `useComposer()`. */ @@ -1032,6 +1098,63 @@ export function renderSlot< }; const navigateCalls: NavigateCall[] = []; + const experimental_fixedTabOpenCalls: ExperimentalFixedTabOpenCall[] = []; + let fixedTabTargetSnapshot = + options.experimental_fixedTabTarget === undefined + ? null + : { + panelId: options.experimental_fixedTabTarget.panelId, + sequence: 1, + tabId: options.experimental_fixedTabTarget.tabId, + target: strictJsonRoundTrip( + options.experimental_fixedTabTarget.target, + "fixed tab target", + ), + }; + const fixedTabTargetListeners = new Set<() => void>(); + const fixedTabTarget: TestFixedTabTargetStore = { + getSnapshot: () => fixedTabTargetSnapshot, + subscribe(listener) { + fixedTabTargetListeners.add(listener); + return () => fixedTabTargetListeners.delete(listener); + }, + consume(sequence) { + if (fixedTabTargetSnapshot?.sequence !== sequence) return; + fixedTabTargetSnapshot = null; + for (const listener of fixedTabTargetListeners) listener(); + }, + }; + const appPanel: ExperimentalAppPanel = { + openFixedTab(panelOptions) { + let target: JsonValue | undefined; + if (panelOptions.target !== undefined) { + try { + target = strictJsonRoundTrip( + panelOptions.target, + "fixed tab open target", + ); + } catch { + return false; + } + if (panelOptions.tab.experimental_target === undefined) return false; + try { + if (!panelOptions.tab.experimental_target.validate(target)) { + return false; + } + } catch { + return false; + } + } + const call: ExperimentalFixedTabOpenCall = { + surface: panelOptions.surface, + panelId: panelOptions.tab.panelId, + tabId: panelOptions.tab.id, + ...(target === undefined ? {} : { target }), + }; + experimental_fixedTabOpenCalls.push(call); + return options.experimental_openFixedTab?.(call) ?? false; + }, + }; const sidebarActionCalls: SidebarActionCall[] = []; const sidebarPullRequests = new Map( Object.entries(options.sidebarPullRequests ?? {}), @@ -1224,6 +1347,9 @@ export function renderSlot< bbContext: { projectId, threadId }, navigate, navigateCalls, + appPanel, + experimental_fixedTabOpenCalls, + fixedTabTarget, composer, composerLog, sidebarThreads, @@ -1298,6 +1424,7 @@ export function renderSlot< setComposerText, setComposerScope, navigateCalls, + experimental_fixedTabOpenCalls, sidebarActionCalls, composer: composerLog, behavior: { @@ -1309,6 +1436,7 @@ export function renderSlot< inspection: { rpcCalls, navigateCalls, + experimental_fixedTabOpenCalls, sidebarActionCalls, composer: composerLog, }, diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 97b7eae4c7..dad9dacc8b 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -546,6 +546,13 @@ experimental_FileLink renders a real explicit live-file anchor whose ordinary activation uses the same preview/file-opener controller as first-party links; its lazy context menu adds Open with, preferred-external, installed-app, and copy actions without reading the file or discovering editors on mount. +An `experimental_fixedTabs` registration with `panelId` equal to its containing +nav panel's `id` is also an owner-scoped reference. Add +`experimental_target: { validate }` for a typed JSON-safe transient target, +select it with `experimental_useAppPanel().openFixedTab({ surface: { kind: +"current" }, tab, target? })`, and read/consume delivery inside the fixed tab +with `experimental_useFixedTabTarget(tab)`. Selection persists; targets never +do. A plugin can address only its own eligible tab on the current nav panel. `import { toast } from "sonner"` reaches the host toaster; react, the portaling radix families, sonner, vaul, @pierre/diffs, and the host-resident clsx, tailwind-merge, and diff --git a/plugins/github/app.test.tsx b/plugins/github/app.test.tsx new file mode 100644 index 0000000000..d742eb0022 --- /dev/null +++ b/plugins/github/app.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; +import { loadPluginApp } from "@get-bb/plugin-sdk/testing/app"; + +const app = await loadPluginApp(() => import("./app")); + +describe("GitHub app fixed-tab navigation", () => { + it("registers an owner-validated targeted details tab", () => { + const details = app.navPanels[0]?.experimental_fixedTabs?.[0]; + expect(details).toMatchObject({ + id: "details", + title: "Details", + icon: "Info", + layout: "flush", + }); + expect( + details?.experimental_target?.validate({ + kind: "item", + itemKind: "pr", + repo: "get-bb/bb", + number: 42, + }), + ).toBe(true); + expect( + details?.experimental_target?.validate({ + kind: "item", + itemKind: "pr", + repo: "get-bb/bb", + number: -1, + }), + ).toBe(false); + }); +}); diff --git a/plugins/github/app.tsx b/plugins/github/app.tsx index de88128965..40c3391826 100644 --- a/plugins/github/app.tsx +++ b/plugins/github/app.tsx @@ -21,10 +21,14 @@ import { experimental_Diff as Diff, experimental_FileLink as FileLink, experimental_UrlLink as UrlLink, + experimental_useAppPanel, + experimental_useFixedTabTarget, useBbNavigate, useRealtime, useRpc, + type ExperimentalPluginFixedTabRegistration, type PluginNavPanelProps, + type JsonValue, type PluginThreadPanelProps, } from "@get-bb/plugin-sdk/app"; import { @@ -67,6 +71,36 @@ import { EmptyState } from "@/components/empty-state"; import { Markdown } from "@/components/markdown-lite"; import { PageBody } from "@/components/page-body"; +type GithubDetailsFixedTabTarget = { + kind: "item"; + itemKind: "issue" | "pr"; + repo: string; + number: number; +}; + +function isGithubDetailsFixedTabTarget( + value: JsonValue, +): value is GithubDetailsFixedTabTarget { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const keys = Object.keys(value); + return ( + keys.length === 4 && + keys.includes("kind") && + keys.includes("itemKind") && + keys.includes("repo") && + keys.includes("number") && + value.kind === "item" && + (value.itemKind === "issue" || value.itemKind === "pr") && + typeof value.repo === "string" && + value.repo.length > 0 && + typeof value.number === "number" && + Number.isSafeInteger(value.number) && + value.number > 0 + ); +} + interface IssueComment { author: string; body: string; @@ -2082,6 +2116,46 @@ function GithubPanel({ subPath }: PluginNavPanelProps) { ); } +function GithubDetailsFixedTab() { + const delivery = experimental_useFixedTabTarget(githubDetailsFixedTab); + const [selection, setSelection] = + useState(null); + useEffect(() => { + if (delivery === null) return; + setSelection(delivery.target); + delivery.consume(); + }, [delivery]); + + if (selection === null) { + return ( + + ); + } + return selection.itemKind === "issue" ? ( + setSelection(null)} + /> + ) : ( + setSelection(null)} + /> + ); +} + +const githubDetailsFixedTab = { + panelId: "github", + id: "details", + title: "Details", + icon: "Info", + component: GithubDetailsFixedTab, + layout: "flush", + experimental_target: { validate: isGithubDetailsFixedTabTarget }, +} satisfies ExperimentalPluginFixedTabRegistration; + function ListView({ kind, query, @@ -2129,6 +2203,24 @@ function GithubPanelBody({ query: string; setQuery: (query: string) => void; }) { + const appPanel = experimental_useAppPanel(); + const openItem = useCallback( + (itemKind: "issue" | "pr", repo: string, number: number) => { + const accepted = appPanel.openFixedTab({ + surface: { kind: "current" }, + tab: githubDetailsFixedTab, + target: { kind: "item", itemKind, repo, number }, + }); + if (!accepted) { + navigate( + itemKind === "pr" + ? { view: "pull", repo, number } + : { view: "issue", repo, number }, + ); + } + }, + [appPanel, navigate], + ); if (status !== null && status.ghState === "unavailable") { return ( - navigate(kind === "pr" ? { view: "pull", repo, number } : { view: "issue", repo, number }) + openItem(kind === "pr" ? "pr" : "issue", repo, number) } />
@@ -2223,6 +2315,7 @@ export default definePluginApp((app) => { path: "github", component: GithubPanel, headerContent: PanelHeader, + experimental_fixedTabs: [githubDetailsFixedTab], }); app.slots.threadPanelAction({ id: "pull", From e9d524c9385d0759109caace9481953af4e211af Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Wed, 19 Aug 2026 22:49:00 -0700 Subject: [PATCH 05/18] Fix plugin navigation lint failures --- .../AppFileExternalNavigationDispatcher.tsx | 21 ++++++++++--------- .../plugin/PluginPanelRightPanelHost.tsx | 1 + apps/app/src/lib/plugin-sdk-hooks.ts | 9 ++++++-- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx index 0d7a1c86dd..d7954b9df0 100644 --- a/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx +++ b/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx @@ -17,18 +17,19 @@ export function AppFileExternalNavigationDispatcher({ const resolvedTarget = useResolvedLiveFileTarget(intent.target, { enabled: true, }); - const localTargets = useLocalOpenTargets({ - enabled: resolvedTarget.status === "available", - ...(resolvedTarget.status === "available" - ? { openContext: resolvedTarget.openContext } - : {}), - }); + const { isLoading: areLocalTargetsLoading, openPathInPreferredFileTarget } = + useLocalOpenTargets({ + enabled: resolvedTarget.status === "available", + ...(resolvedTarget.status === "available" + ? { openContext: resolvedTarget.openContext } + : {}), + }); useEffect(() => { if ( didSettleRef.current || resolvedTarget.status === "loading" || - localTargets.isLoading + areLocalTargetsLoading ) { return; } @@ -41,15 +42,15 @@ export function AppFileExternalNavigationDispatcher({ return; } const location = getExperimentalFileLocationStart(intent.location); - void localTargets.openPathInPreferredFileTarget({ + void openPathInPreferredFileTarget({ columnNumber: location.columnNumber, lineNumber: location.lineNumber, path: resolvedTarget.absolutePath, }); }, [ intent.location, - localTargets.isLoading, - localTargets.openPathInPreferredFileTarget, + areLocalTargetsLoading, + openPathInPreferredFileTarget, onSettled, resolvedTarget, ]); diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx index cb3899edef..352508310b 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx @@ -349,6 +349,7 @@ export function PluginPanelRightPanelHost({ [ fixedViewTabs, fixedTabOwnerId, + fixedTabTargetSequenceRef, panel?.experimental_fixedTabs, revealPanel, updatePanelState, diff --git a/apps/app/src/lib/plugin-sdk-hooks.ts b/apps/app/src/lib/plugin-sdk-hooks.ts index 4548b7a337..ecde6d9024 100644 --- a/apps/app/src/lib/plugin-sdk-hooks.ts +++ b/apps/app/src/lib/plugin-sdk-hooks.ts @@ -394,7 +394,7 @@ export function useBbNavigate(): BbNavigate { ); } -export function experimental_useAppPanel(): ExperimentalAppPanel { +function useExperimentalAppPanel(): ExperimentalAppPanel { const pluginId = usePluginId(); const appNavigation = useAppNavigationHost(); const openFixedTab = useCallback( @@ -420,7 +420,7 @@ export function experimental_useAppPanel(): ExperimentalAppPanel { return useMemo(() => ({ openFixedTab }), [openFixedTab]); } -export function experimental_useFixedTabTarget( +function useExperimentalFixedTabTarget( tab: ExperimentalPluginFixedTabReference, ): ExperimentalFixedTabTargetDelivery | null { const pluginId = usePluginId(); @@ -441,6 +441,11 @@ export function experimental_useFixedTabTarget( }; } +export { + useExperimentalAppPanel as experimental_useAppPanel, + useExperimentalFixedTabTarget as experimental_useFixedTabTarget, +}; + function reconcileComposerMentions( currentText: string, nextText: string, From e6ab42edbec2db21db1c417e7e0805abd5fa7d50 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 20 Aug 2026 09:28:08 -0700 Subject: [PATCH 06/18] Require explicit fixed tab owners --- .../plugin/PluginPanelRightPanelHost.test.tsx | 7 +++++ .../bb-plugin-authoring/SKILL.md | 10 +++---- docs/api_to_audit.md | 6 ++--- packages/plugin-sdk/README.md | 6 ++--- packages/plugin-sdk/src/app-contract.ts | 13 ++------- .../src/internal/plugin-app-collector.ts | 12 +++++---- .../testing/__tests__/app-harness.test.tsx | 27 +++++++++++++++++++ .../src/templates/bb-guide-plugins.md | 4 +-- plugins/docs/app.test.tsx | 1 + plugins/docs/app.tsx | 1 + plugins/tasks/app.tsx | 1 + 11 files changed, 59 insertions(+), 29 deletions(-) diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx index c94b74569d..364d8ca9f9 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx @@ -28,6 +28,7 @@ import { } from "@/lib/app-fixed-tab-navigation"; interface TestFixedTabRegistration { + panelId: string; id: string; title: string; icon: string; @@ -589,12 +590,14 @@ describe("PluginPanelRightPanelHost", () => { } fixedTabState.registrations = [ { + panelId: "board", id: "navigation", title: "Navigation", icon: "PanelRight", component: Navigation, }, { + panelId: "board", id: "details", title: "Details", icon: "Info", @@ -655,12 +658,14 @@ describe("PluginPanelRightPanelHost", () => { } fixedTabState.registrations = [ { + panelId: "board", id: "navigation", title: "Navigation", icon: "PanelRight", component: () =>
Navigation
, }, { + panelId: "board", id: "details", title: "Details", icon: "Info", @@ -726,6 +731,7 @@ describe("PluginPanelRightPanelHost", () => { it("does not reopen fixed tabs after navigating away and back", async () => { fixedTabState.registrations = [ { + panelId: "board", id: "navigation", title: "Navigation", icon: "PanelRight", @@ -773,6 +779,7 @@ describe("PluginPanelRightPanelHost", () => { it("preserves a closed fixed tab while its plugin registration is loading", async () => { fixedTabState.registrations = [ { + panelId: "board", id: "navigation", title: "Navigation", icon: "PanelRight", diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 126b8d4712..cda6057fff 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -1296,6 +1296,7 @@ export default definePluginApp((app) => { component: Board, experimental_fixedTabs: [ { + panelId: "board", id: "navigation", title: "Navigation", icon: "PanelRight", @@ -1603,7 +1604,7 @@ Slot props contracts (versioned, additive-only): `experimental_fixedTabs` declares ordered, non-closable page views in that same host tab strip: - `{ id, panelId?, title, icon, component, layout?, experimental_target? }`. + `{ id, panelId, title, icon, component, layout?, experimental_target? }`. BB opens the first fixed tab on the page's first wide-layout visit, but remembers a later user close. Only the active fixed-tab component is mounted, and closing the @@ -1613,10 +1614,9 @@ Slot props contracts (versioned, additive-only): do not replace its native chrome, Browser, Terminal, or keyboard commands. Experimental: see `docs/api_to_audit.md`. - A registration whose `panelId` exactly matches its containing nav panel's - `id` is also the stable reference for selecting that plugin-owned tab. - Existing untargeted declarations may omit `panelId`; add it whenever code - will address the tab. A targetable tab declares + Every registration's `panelId` must exactly match its containing nav panel's + `id`; the registration is also the stable reference for selecting that + plugin-owned tab. A targetable tab declares `experimental_target: { validate(value): value is Target }`; BB checks JSON safety before calling the owner validator. From any component of the same plugin on that page, call diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 2452af8b2c..8b99383a2c 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -183,9 +183,9 @@ Browser and Terminal tools, and only mounts the active plugin component while the panel is open. A fixed tab receives the nav page's current `subPath`; `layout: "padded"` uses host padding and scrolling, while `layout: "flush"` gives the component the whole content region. On the first visit the first declared fixed tab opens on -wide layouts. A later user close remains closed. A fixed-tab registration is -also its stable, plugin-owner-and-panel-scoped reference when its `panelId` -matches the containing nav panel's id. `experimental_useAppPanel()` +wide layouts. A later user close remains closed. Every fixed-tab registration +must include a `panelId` matching its containing nav panel and is also its +stable, plugin-owner-and-panel-scoped reference. `experimental_useAppPanel()` can select one of the calling plugin's eligible tabs on the current surface and optionally submit a JSON-safe target. The tab's `experimental_target` validator owns the target type and policy; `experimental_useFixedTabTarget()` diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index ee12e6147d..5fb51d86de 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -38,9 +38,9 @@ the current host accepted the intent. Targets never infer an ambient workspace. The frontend harness records both methods and accepts `openFilePreview` and `openFileExternally` behavior options. -A nav panel's `experimental_fixedTabs` entries are also stable references to -that plugin's own tabs when their `panelId` matches the containing nav panel's -`id`. Give a targeted tab an `experimental_target.validate` type guard, call +A nav panel's `experimental_fixedTabs` entries must include the containing nav +panel's `id` as `panelId`; each entry is also a stable reference to that +plugin's own tab. Give a targeted tab an `experimental_target.validate` type guard, call `experimental_useAppPanel().openFixedTab({ surface: { kind: "current" }, tab, target })`, and read the in-memory delivery inside the tab with `experimental_useFixedTabTarget(tab)`. Call `consume()` after applying it. diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index 9a318a8ab1..152885c1b8 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -390,18 +390,9 @@ export type ExperimentalPluginFixedTabRegistration< layout?: "padded" | "flush"; }; -interface LegacyPluginFixedTabRegistration { - id: string; - title: string; - icon: string; - component: ComponentType; - layout?: "padded" | "flush"; - panelId?: string; - experimental_target?: never; -} - +/** A fixed tab with either no target or an owner-validated JSON target. */ export type ExperimentalPluginFixedTabDeclaration = - | LegacyPluginFixedTabRegistration + | ExperimentalPluginFixedTabRegistration | ExperimentalPluginFixedTabRegistration; export interface PluginNavPanelRegistration { diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts index 55ddd4a7e9..7c25bf8d99 100644 --- a/packages/plugin-sdk/src/internal/plugin-app-collector.ts +++ b/packages/plugin-sdk/src/internal/plugin-app-collector.ts @@ -185,10 +185,12 @@ export function collectPluginAppRegistrations( `${fixedTabKind}: "layout" must be "padded" or "flush" when set`, ); } - if ( - fixedTab?.panelId !== undefined && - fixedTab.panelId !== panelId - ) { + const fixedTabPanelId = requireNonEmptyString( + fixedTabKind, + "panelId", + fixedTab?.panelId, + ); + if (fixedTabPanelId !== panelId) { throw new Error( `${fixedTabKind}: "panelId" must match its containing navPanel id ${JSON.stringify(panelId)}`, ); @@ -207,7 +209,7 @@ export function collectPluginAppRegistrations( } return { id, - panelId, + panelId: fixedTabPanelId, title: requireNonEmptyString( fixedTabKind, "title", diff --git a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx index 7f95aad0d4..1869f93802 100644 --- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx +++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx @@ -790,6 +790,31 @@ describe("loadPluginApp", () => { ); }); + it("rejects a fixed-tab registration without an owner panel", async () => { + await expect( + loadPluginApp( + definePluginApp((builder) => { + builder.slots.navPanel({ + id: "tasks", + title: "Tasks", + icon: "ListTodo", + path: "tasks", + component: Panel, + experimental_fixedTabs: [ + // @ts-expect-error Runtime collector coverage for malformed JS. + { + id: "navigation", + title: "Navigation", + icon: "PanelRight", + component: Panel, + }, + ], + }); + }), + ), + ).rejects.toThrow('"panelId" must be a non-empty string'); + }); + it("rejects duplicate nav panel fixed tab ids", async () => { await expect( loadPluginApp( @@ -802,12 +827,14 @@ describe("loadPluginApp", () => { component: Panel, experimental_fixedTabs: [ { + panelId: "tasks", id: "navigation", title: "First", icon: "PanelRight", component: Panel, }, { + panelId: "tasks", id: "navigation", title: "Second", icon: "PanelRight", diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index dad9dacc8b..943aaf1f0a 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -546,8 +546,8 @@ experimental_FileLink renders a real explicit live-file anchor whose ordinary activation uses the same preview/file-opener controller as first-party links; its lazy context menu adds Open with, preferred-external, installed-app, and copy actions without reading the file or discovering editors on mount. -An `experimental_fixedTabs` registration with `panelId` equal to its containing -nav panel's `id` is also an owner-scoped reference. Add +Every `experimental_fixedTabs` registration must include `panelId` equal to its +containing nav panel's `id`; it is also an owner-scoped reference. Add `experimental_target: { validate }` for a typed JSON-safe transient target, select it with `experimental_useAppPanel().openFixedTab({ surface: { kind: "current" }, tab, target? })`, and read/consume delivery inside the fixed tab diff --git a/plugins/docs/app.test.tsx b/plugins/docs/app.test.tsx index 0ccf7cfe79..f9c15cee58 100644 --- a/plugins/docs/app.test.tsx +++ b/plugins/docs/app.test.tsx @@ -143,6 +143,7 @@ describe("Docs nav panel", () => { path: "docs", experimental_fixedTabs: [ { + panelId: "docs", id: "navigation", title: "Navigation", icon: "ListView", diff --git a/plugins/docs/app.tsx b/plugins/docs/app.tsx index a2ce86417d..5771a4ed1a 100644 --- a/plugins/docs/app.tsx +++ b/plugins/docs/app.tsx @@ -2276,6 +2276,7 @@ export default definePluginApp((app) => { component: NotesPanel, experimental_fixedTabs: [ { + panelId: "docs", id: "navigation", title: "Navigation", icon: "ListView", diff --git a/plugins/tasks/app.tsx b/plugins/tasks/app.tsx index 4a6c41f069..69aa68db86 100644 --- a/plugins/tasks/app.tsx +++ b/plugins/tasks/app.tsx @@ -14,6 +14,7 @@ export default definePluginApp((app) => { experimental_sidebarAccessory: TasksSidebarAccessory, experimental_fixedTabs: [ { + panelId: "tasks", id: "navigation", title: "Navigation", icon: "ListView", From d1e9b813cf4ee12cf6098c0447ce7623a4a74478 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 20 Aug 2026 10:06:46 -0700 Subject: [PATCH 07/18] Migrate all first-party plugin navigation links --- .../plugins/thread-chat-demo/package.json | 2 +- plugins/connect/app.test.tsx | 10 +++- plugins/connect/app.tsx | 36 ++++++----- plugins/connect/package.json | 3 +- plugins/docs/package.json | 2 +- .../github/components/markdown-lite.test.tsx | 59 +++++++++++++++---- plugins/github/components/markdown-lite.tsx | 5 +- plugins/github/package.json | 2 +- plugins/tasks/package.json | 2 +- plugins/tasks/views/detail/threads.test.tsx | 8 ++- plugins/tasks/views/detail/threads.tsx | 10 +++- 11 files changed, 101 insertions(+), 38 deletions(-) diff --git a/examples/plugins/thread-chat-demo/package.json b/examples/plugins/thread-chat-demo/package.json index 43cad58147..1e34eb97c0 100644 --- a/examples/plugins/thread-chat-demo/package.json +++ b/examples/plugins/thread-chat-demo/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.3" + "bbPluginSdk": ">=0.4.10" }, "bb": { "name": "ThreadChat demo", diff --git a/plugins/connect/app.test.tsx b/plugins/connect/app.test.tsx index 47d4efcb21..56f531603e 100644 --- a/plugins/connect/app.test.tsx +++ b/plugins/connect/app.test.tsx @@ -63,13 +63,21 @@ describe("connect settings section", () => { const slot = renderSlot( app.settingsSections[0]!, {}, - { rpc: { status: () => status({ dashboardUrl }) } }, + { + openUrl: () => true, + rpc: { status: () => status({ dashboardUrl }) }, + }, ); const link = (await slot.findByRole("link", { name: "Get a connect code", })) as HTMLAnchorElement; expect(link.href).toBe(dashboardUrl); + fireEvent.click(link); + expect(slot.navigateCalls).toContainEqual({ + method: "experimental_openUrl", + url: dashboardUrl, + }); slot.getByText("you.bb.localhost:42745"); slot.getByText(/your bb\.localhost:42745 dashboard/); }); diff --git a/plugins/connect/app.tsx b/plugins/connect/app.tsx index 875d82fd1c..d717594a16 100644 --- a/plugins/connect/app.tsx +++ b/plugins/connect/app.tsx @@ -8,7 +8,12 @@ // (amber wash + dimmed body). Disconnect confirms in a dialog, then lands on // the unpaired card with a transient receipt. import { useCallback, useEffect, useRef, useState } from "react"; -import { definePluginApp, useRealtime, useRpc } from "@get-bb/plugin-sdk/app"; +import { + definePluginApp, + experimental_UrlLink as UrlLink, + useRealtime, + useRpc, +} from "@get-bb/plugin-sdk/app"; import { encodeMobilePairingPayload, mobilePairingPayload, @@ -300,7 +305,7 @@ function UrlHero({ url, showOpen }: { url: string; showOpen: boolean }) { const [copyState, setCopyState] = useState<"idle" | "copied" | "manual">( "idle", ); - const urlRef = useRef(null); + const urlRef = useRef(null); const timerRef = useRef | null>(null); useEffect( () => () => { @@ -337,15 +342,14 @@ function UrlHero({ url, showOpen }: { url: string; showOpen: boolean }) { return (
- - {url} - + {url} + ) : null}
@@ -501,14 +505,14 @@ function PairForm({ {copy !== null ? (
{copy.lead}{" "} - {copy.linkLabel} - + {copy.tail}
) : null} @@ -751,14 +755,14 @@ function AddMobileDeviceSectionContent({ {errorCode === "machine_limit" ? (
Your {dashboardHost} account has reached its machine limit.{" "} - Revoke a device you no longer use - {" "} + {" "} in the dashboard, then try again.
) : errorCode !== null ? ( @@ -916,14 +920,14 @@ function SharedPortsSection({ {share.url ? ( <> - {hostOf(share.url)} - +
diff --git a/plugins/connect/package.json b/plugins/connect/package.json index d5d9978fa5..b2ea723fca 100644 --- a/plugins/connect/package.json +++ b/plugins/connect/package.json @@ -5,7 +5,8 @@ "type": "module", "description": "Remote access via getbb.app — this bb becomes reachable at https://.getbb.app. Disable to cut off all remote access.", "engines": { - "bb": ">=0.0" + "bb": ">=0.0", + "bbPluginSdk": ">=0.4.10" }, "bb": { "name": "Remote access", diff --git a/plugins/docs/package.json b/plugins/docs/package.json index 620e7b7eb4..0acc11bbee 100644 --- a/plugins/docs/package.json +++ b/plugins/docs/package.json @@ -23,7 +23,7 @@ ], "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.8" + "bbPluginSdk": ">=0.4.10" }, "bb": { "name": "Docs", diff --git a/plugins/github/components/markdown-lite.test.tsx b/plugins/github/components/markdown-lite.test.tsx index 1f81620a2d..7afe6deef3 100644 --- a/plugins/github/components/markdown-lite.test.tsx +++ b/plugins/github/components/markdown-lite.test.tsx @@ -1,18 +1,28 @@ -import { renderToStaticMarkup } from "react-dom/server"; +// @vitest-environment jsdom + import { describe, expect, it } from "vitest"; -import { Markdown } from "./markdown-lite"; +import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; + +await loadPluginApp(() => import("../app")); +const { Markdown } = await import("./markdown-lite"); describe("Markdown", () => { it("renders GFM tables in pull request descriptions", () => { - const markup = renderToStaticMarkup( - ( + , + /> + ), + }, + {}, ); + const markup = slot.container.innerHTML; expect(markup).toContain(" { expect(markup).toContain(" { - const markup = renderToStaticMarkup( - ( + , + /> + ), + }, + {}, ); + const markup = slot.container.innerHTML; expect(markup.match(/)/g)).toHaveLength(3); expect(markup.match(/)/g)).toHaveLength(3); @@ -41,5 +58,27 @@ After the table.`} expect(markup).toContain("uses | safely"); expect(markup).toContain(" { + const slot = renderSlot( + { + component: () => ( + + ), + }, + {}, + { openUrl: () => true }, + ); + + slot.getByRole("link", { name: "Open issue" }).click(); + expect(slot.navigateCalls).toEqual([ + { + method: "experimental_openUrl", + url: "https://github.com/get-bb/bb/issues/1", + }, + ]); + slot.unmount(); }); }); diff --git a/plugins/github/components/markdown-lite.tsx b/plugins/github/components/markdown-lite.tsx index f8494880fc..2b39ce926a 100644 --- a/plugins/github/components/markdown-lite.tsx +++ b/plugins/github/components/markdown-lite.tsx @@ -5,6 +5,7 @@ // markdown). Everything is built as React elements — img attributes are // extracted and whitelisted, so no HTML is ever injected. import { cn } from "@bb/shared-ui/lib/utils"; +import { experimental_UrlLink as UrlLink } from "@get-bb/plugin-sdk/app"; const INLINE_PATTERN = // Image forms first: `![…](…)` must win over the link pattern (which @@ -91,7 +92,7 @@ function renderInline(text: string): React.ReactNode[] { const label = token.slice(1, closeBracket); const href = token.slice(closeBracket + 2, -1); nodes.push( - {renderInline(label)} - , + , ); } last = index + token.length; diff --git a/plugins/github/package.json b/plugins/github/package.json index 6155dcc166..7c56aa83db 100644 --- a/plugins/github/package.json +++ b/plugins/github/package.json @@ -23,7 +23,7 @@ ], "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.3" + "bbPluginSdk": ">=0.4.10" }, "bb": { "name": "GitHub", diff --git a/plugins/tasks/package.json b/plugins/tasks/package.json index f6aab97621..ffb845b6aa 100644 --- a/plugins/tasks/package.json +++ b/plugins/tasks/package.json @@ -23,7 +23,7 @@ ], "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.8" + "bbPluginSdk": ">=0.4.10" }, "bb": { "name": "Tasks", diff --git a/plugins/tasks/views/detail/threads.test.tsx b/plugins/tasks/views/detail/threads.test.tsx index 00b359b5e8..8e4b7bc1d5 100644 --- a/plugins/tasks/views/detail/threads.test.tsx +++ b/plugins/tasks/views/detail/threads.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; @@ -101,6 +101,7 @@ describe("task detail pull request pills", () => { app.navPanels[0]!, { subPath: "task/TSK-5" }, { + openUrl: () => true, rpc: detailRpc({ listTaskPullRequests: () => ({ pullRequests: [ @@ -127,6 +128,11 @@ describe("task detail pull request pills", () => { expect(link.target).toBe("_blank"); expect(link.rel).toContain("noopener"); expect(link.textContent).toContain("#12"); + fireEvent.click(link); + expect(slot.navigateCalls).toContainEqual({ + method: "experimental_openUrl", + url: "https://github.com/acme/bb/pull/12", + }); }); it("marks threads whose PR lookup failed and stays quiet otherwise", async () => { diff --git a/plugins/tasks/views/detail/threads.tsx b/plugins/tasks/views/detail/threads.tsx index 36b90bb5c5..597bef0089 100644 --- a/plugins/tasks/views/detail/threads.tsx +++ b/plugins/tasks/views/detail/threads.tsx @@ -1,5 +1,9 @@ import { useState } from "react"; -import { useBbNavigate, useRpc } from "@get-bb/plugin-sdk/app"; +import { + experimental_UrlLink as UrlLink, + useBbNavigate, + useRpc, +} from "@get-bb/plugin-sdk/app"; import type { DelegationRpcContract } from "../../delegate/contract.js"; import type { Preset, @@ -40,7 +44,7 @@ function ThreadPullRequestPill({ if (pullRequest) { const meta = PR_STATE_META[pullRequest.state]; return ( - # {pullRequest.number} - + ); } if (unavailable) { From a2404964b2ede9417ccd746f963be4aaebb812b8 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 20 Aug 2026 10:07:11 -0700 Subject: [PATCH 08/18] Prove core Diff uses generic fixed-tab routing --- .../git-diff-fixed-tab-navigation.test.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts index a3058a7788..cb4ccc97cc 100644 --- a/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts +++ b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -import { createGitDiffFixedTabDestination } from "./git-diff-fixed-tab-navigation"; +import { openAppFixedTabFromDestinations } from "@/lib/app-fixed-tab-navigation"; +import { + createGitDiffFixedTabDestination, + GIT_DIFF_FIXED_TAB_REFERENCE, +} from "./git-diff-fixed-tab-navigation"; describe("createGitDiffFixedTabDestination", () => { - it("owns file and commit target validation outside the generic controller", () => { + it("routes core Changes targets through the generic controller while the owner validates them", () => { const openCommit = vi.fn(); const openFile = vi.fn(); const openOrdinary = vi.fn(); @@ -13,10 +17,19 @@ describe("createGitDiffFixedTabDestination", () => { openOrdinary, }); - expect(destination.open({ kind: "file", path: "src/app.tsx" })).toBe(true); - expect(destination.open({ kind: "commit", sha: "abc123" })).toBe(true); - expect(destination.open({ kind: "file", path: "" })).toBe(false); - expect(destination.open(undefined)).toBe(true); + const open = ( + target?: { kind: "file"; path: string } | { kind: "commit"; sha: string }, + ) => + openAppFixedTabFromDestinations([destination], { + surface: { kind: "current" }, + tab: GIT_DIFF_FIXED_TAB_REFERENCE, + ...(target === undefined ? {} : { target }), + }); + + expect(open({ kind: "file", path: "src/app.tsx" })).toBe(true); + expect(open({ kind: "commit", sha: "abc123" })).toBe(true); + expect(open({ kind: "file", path: "" })).toBe(false); + expect(open()).toBe(true); expect(openFile).toHaveBeenCalledWith("src/app.tsx"); expect(openCommit).toHaveBeenCalledWith("abc123"); expect(openOrdinary).toHaveBeenCalledOnce(); From 5cd3e33500a862042307a78b172166a3ae5067a3 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 20 Aug 2026 11:02:59 -0700 Subject: [PATCH 09/18] Consolidate secondary panel file opening --- .../secondary-panel/useThreadFileTabs.test.ts | 45 +++++- .../secondary-panel/useThreadFileTabs.ts | 130 +++++------------- 2 files changed, 78 insertions(+), 97 deletions(-) diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts index d5922a4f14..5797132b64 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts @@ -616,11 +616,8 @@ describe("useThreadFileTabs file opener diversion", () => { expect(result.current.activeWorkspaceFilePath).toBe("src/index.ts"); }); - // The file search builds its tab through its own path (it replaces the - // new-tab screen rather than appending a tab), so diversion has to be - // applied there too. It was not, and every file picked from the "+" screen - // silently got the built-in preview while links and `bb thread open` - // diverted correctly. + // File search replaces the new-tab screen rather than appending a tab, but + // it must use the same opener resolution as links and `bb thread open`. it("diverts a workspace file picked from the file search", () => { registerNotesOpener(); const { result } = renderThreadHook(() => @@ -662,6 +659,44 @@ describe("useThreadFileTabs file opener diversion", () => { ).toEqual(["plugin-panel"]); }); + it("diverts a thread-storage file picked from the file search", () => { + registerNotesOpener(); + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "opener-storage-search", + syncThreadId: "thr_storage_search", + environmentId: "env_1", + storageFiles: [{ path: "artifacts/notes.md" }], + terminalSessions: undefined, + }), + ); + + act(() => result.current.openTab({ kind: "new-tab" })); + act(() => + result.current.selectFileSearchResult({ + source: "thread-storage", + path: "artifacts/notes.md", + }), + ); + + expect(result.current.activePluginPanelTab).toMatchObject({ + kind: "plugin-panel", + pluginId: "notes", + actionId: "file-opener:editor", + title: "notes.md", + fileOpenerOwner: { + kind: "thread-storage-file-preview", + environmentId: "env_1", + threadId: "thr_storage_search", + tab: { path: "artifacts/notes.md" }, + }, + }); + expect(result.current.isNewTabActive).toBe(false); + expect( + result.current.orderedSecondaryFileTabs.map((tab) => tab.kind), + ).toEqual(["plugin-panel"]); + }); + it("keeps the built-in preview for an unmatched file search extension", () => { registerNotesOpener(); const { result } = renderThreadHook(() => diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index b287cd703b..d99c72104c 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -122,13 +122,6 @@ interface CreateTabForOpenRequestArgs { threadId: string | null | undefined; } -interface CreateTabForFileSearchSelectionArgs { - projectId: string | null; - resolvedEnvironmentId: string | null | undefined; - selection: FileSearchSelection; - threadId: string | null | undefined; -} - interface PruneSecondaryTabsArgs { activeTabId: string | null; stateTabs: readonly FixedPanelTab[]; @@ -143,6 +136,8 @@ type SecondaryPanelTab = | NewTabFixedPanelTab | PluginPanelFixedPanelTab; +type OpenResolvedTabBehavior = "open" | "replace-new-tab"; + // Every side chat uses a constant tab title; the message it was triggered from // is shown inside the panel ("Replying to" bubble), so the tab needn't echo it. @@ -213,38 +208,28 @@ function createTabForOpenRequest({ } } -function createTabForFileSearchSelection({ - projectId, - resolvedEnvironmentId, - selection, - threadId, -}: CreateTabForFileSearchSelectionArgs): - | WorkspaceFilePreviewFixedPanelTab - | ThreadStorageFilePreviewFixedPanelTab - | null { +function openRequestForFileSearchSelection( + selection: FileSearchSelection, +): OpenSecondaryPanelTabRequest { if (selection.source === "workspace") { - if (resolvedEnvironmentId === undefined) return null; - return createWorkspaceFilePreviewFixedPanelTab({ - environmentId: resolvedEnvironmentId, - projectId: resolvedEnvironmentId === null ? projectId : null, + return { + kind: "workspace-file-preview", tab: { lineRange: null, path: selection.path, source: { kind: "working-tree" }, statusLabel: null, }, - }); + }; } - if (!threadId) return null; - return createStorageTab( - resolvedEnvironmentId ?? null, - { + return { + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: selection.path, }, - threadId, - ); + }; } function setPrunedSecondaryTabs({ @@ -441,16 +426,12 @@ export function useThreadFileTabs({ const { fileOpeners } = usePluginSlots(); const fileOpenerPreference = useFileOpenerPreferenceValue(); - const openTab = useCallback( + const openResolvedTab = useCallback( ( request: OpenSecondaryPanelTabRequest, - options?: { viewer?: FileTabViewerOverride }, + behavior: OpenResolvedTabBehavior, + viewer?: FileTabViewerOverride, ): SecondaryPanelTab | null => { - // Opener diversion (plugin design §5.2): every file-open flow - // funnels through here (links, file search, `bb thread open`), so a - // matching plugin opener applies uniformly. Falls through to the - // built-in tab when no opener matches; a link menu's per-open viewer - // choice overrides automatic or pinned resolution in either direction. const openerTab = createFileOpenerTabForRequest({ fileOpeners, preference: fileOpenerPreference, @@ -459,7 +440,7 @@ export function useThreadFileTabs({ request, resolvedEnvironmentId, threadId: resolvedFileOwnerThreadId, - ...(options?.viewer !== undefined ? { viewer: options.viewer } : {}), + ...(viewer !== undefined ? { viewer } : {}), }); const tab = openerTab ?? @@ -482,7 +463,7 @@ export function useThreadFileTabs({ } updateFixedPanelTabsState((state) => { - if (request.kind === "browser") { + if (behavior === "replace-new-tab") { return replaceNewTabWithSecondaryPanelTabInState({ state, tab }); } return openSecondaryPanelTabInState({ state, tab }); @@ -501,6 +482,23 @@ export function useThreadFileTabs({ ], ); + const openTab = useCallback( + ( + request: OpenSecondaryPanelTabRequest, + options?: { viewer?: FileTabViewerOverride }, + ): SecondaryPanelTab | null => { + // Browser navigation replaces the transient new-tab launcher. Other + // ordinary opens append or focus a tab. Both paths still share the + // same opener-or-built-in resolution above. + return openResolvedTab( + request, + request.kind === "browser" ? "replace-new-tab" : "open", + options?.viewer, + ); + }, + [openResolvedTab], + ); + const activateTab = useCallback( (tabId: string) => { updateFixedPanelTabsState((state) => @@ -552,64 +550,12 @@ export function useThreadFileTabs({ const selectFileSearchResult = useCallback( (selection: FileSearchSelection) => { - // Opener diversion, same as `openTab`. The file search builds its tab - // through its own path (it replaces the new-tab screen rather than - // appending a tab), so without this a plugin `fileOpener` was skipped - // for every file picked from the "+" screen while still applying to - // links and `bb thread open`. - const openerTab = createFileOpenerTabForRequest({ - fileOpeners, - preference: fileOpenerPreference, - projectHostId, - projectId, - request: - selection.source === "workspace" - ? { - kind: "workspace-file-preview", - tab: { - lineRange: null, - path: selection.path, - source: { kind: "working-tree" }, - statusLabel: null, - }, - } - : { - kind: "thread-storage-file-preview", - tab: { lineRange: null, path: selection.path }, - }, - resolvedEnvironmentId, - threadId: resolvedFileOwnerThreadId, - }); - const tab = - openerTab ?? - createTabForFileSearchSelection({ - projectId, - resolvedEnvironmentId, - selection, - threadId: resolvedFileOwnerThreadId, - }); - if (tab === null) return; - - if (selection.source === "workspace") { - recordRecentItem({ source: "workspace", path: selection.path }); - } else { - recordRecentItem({ source: "thread-storage", path: selection.path }); - } - - updateFixedPanelTabsState((state) => - replaceNewTabWithSecondaryPanelTabInState({ state, tab }), + openResolvedTab( + openRequestForFileSearchSelection(selection), + "replace-new-tab", ); }, - [ - fileOpenerPreference, - fileOpeners, - projectHostId, - projectId, - recordRecentItem, - resolvedEnvironmentId, - resolvedFileOwnerThreadId, - updateFixedPanelTabsState, - ], + [openResolvedTab], ); const updateBrowserTab = useCallback( From d11f91d7fa1585d709bb21a44e76f61131e9abdd Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 20 Aug 2026 11:04:11 -0700 Subject: [PATCH 10/18] Share plugin panel full-region policy --- .../plugin/plugin-panel-tab-layout.test.ts | 58 +++++++++++++++++++ .../plugin/plugin-panel-tab-layout.ts | 21 +++++++ apps/app/src/views/RootComposeView.tsx | 13 ++--- .../views/thread-detail/ThreadDetailView.tsx | 20 ++----- 4 files changed, 90 insertions(+), 22 deletions(-) create mode 100644 apps/app/src/components/plugin/plugin-panel-tab-layout.test.ts create mode 100644 apps/app/src/components/plugin/plugin-panel-tab-layout.ts diff --git a/apps/app/src/components/plugin/plugin-panel-tab-layout.test.ts b/apps/app/src/components/plugin/plugin-panel-tab-layout.test.ts new file mode 100644 index 0000000000..3f005f786b --- /dev/null +++ b/apps/app/src/components/plugin/plugin-panel-tab-layout.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import type { PluginPanelFixedPanelTab } from "@/lib/fixed-panel-tabs-state"; +import { pluginPanelTabFillsRegion } from "./plugin-panel-tab-layout"; + +const ACTION_TAB: PluginPanelFixedPanelTab = { + actionId: "viewer", + id: "plugin-panel:docs:viewer:null", + kind: "plugin-panel", + paramsJson: null, + pluginId: "docs", + title: "Viewer", +}; + +describe("pluginPanelTabFillsRegion", () => { + it("fills the region for file-opener tabs independently of panel actions", () => { + expect( + pluginPanelTabFillsRegion( + { + ...ACTION_TAB, + fileOpenerOwner: { + environmentId: "env_1", + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "artifact.md" }, + threadId: "thr_1", + }, + }, + [], + ), + ).toBe(true); + }); + + it("fills the region only for the matching flush action", () => { + expect( + pluginPanelTabFillsRegion(ACTION_TAB, [ + { + id: "viewer", + layout: "flush", + pluginId: "docs", + }, + ]), + ).toBe(true); + expect( + pluginPanelTabFillsRegion(ACTION_TAB, [ + { + id: "viewer", + layout: "padded", + pluginId: "docs", + }, + { + id: "viewer", + layout: "flush", + pluginId: "tasks", + }, + ]), + ).toBe(false); + expect(pluginPanelTabFillsRegion(null, [])).toBe(false); + }); +}); diff --git a/apps/app/src/components/plugin/plugin-panel-tab-layout.ts b/apps/app/src/components/plugin/plugin-panel-tab-layout.ts new file mode 100644 index 0000000000..8fd4510c7b --- /dev/null +++ b/apps/app/src/components/plugin/plugin-panel-tab-layout.ts @@ -0,0 +1,21 @@ +import type { PluginPanelFixedPanelTab } from "@/lib/fixed-panel-tabs-state"; + +interface PluginPanelLayoutAction { + readonly id: string; + readonly layout?: "padded" | "flush"; + readonly pluginId: string; +} + +export function pluginPanelTabFillsRegion( + tab: PluginPanelFixedPanelTab | null, + actions: readonly PluginPanelLayoutAction[], +): boolean { + if (tab === null) return false; + if (tab.fileOpenerOwner !== undefined) return true; + return actions.some( + (action) => + action.pluginId === tab.pluginId && + action.id === tab.actionId && + action.layout === "flush", + ); +} diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 334f591327..b30fb43355 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -71,6 +71,7 @@ import { PluginPanelTabContent, usePluginNewThreadPanelActions, } from "@/components/plugin/PluginPanelActions"; +import { pluginPanelTabFillsRegion } from "@/components/plugin/plugin-panel-tab-layout"; import { usePluginSlots } from "@/lib/plugin-slots"; import { useCreateThread } from "@/hooks/mutations/thread-runtime-mutations"; import { @@ -2460,14 +2461,10 @@ function RootComposeSurface({ : undefined), fileTabs, fileTabContent, - fileTabContentFillsRegion: - activePluginPanelTab !== null && - (activePluginPanelTab.fileOpenerOwner !== undefined || - rootPanelNewThreadPanelActions.find( - (candidate) => - candidate.pluginId === activePluginPanelTab.pluginId && - candidate.id === activePluginPanelTab.actionId, - )?.layout === "flush"), + fileTabContentFillsRegion: pluginPanelTabFillsRegion( + activePluginPanelTab, + rootPanelNewThreadPanelActions, + ), renderBrowserDeck, isBrowserTabActive, isOpen: isSecondaryPanelOpen, diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 6cb17b4844..1cb1a05c53 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -186,6 +186,7 @@ import { PluginPanelTabContent, usePluginPanelActions, } from "@/components/plugin/PluginPanelActions"; +import { pluginPanelTabFillsRegion } from "@/components/plugin/plugin-panel-tab-layout"; import { PluginThreadPanelNavigationProvider } from "@/components/plugin/plugin-thread-panel-navigation"; import { ThreadTimelineNavigationProvider } from "@/components/thread/timeline/ThreadTimelineNavigationContext"; import { usePluginSlots } from "@/lib/plugin-slots"; @@ -3020,25 +3021,16 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { workspaceRootPath: environment?.path, fileTabs, fileTabContent, - fileTabContentFillsRegion: - activePluginPanelTab !== null && - (activePluginPanelTab.fileOpenerOwner !== undefined || - pluginThreadPanelActions.find( - (candidate) => - candidate.pluginId === activePluginPanelTab.pluginId && - candidate.id === activePluginPanelTab.actionId, - )?.layout === "flush"), + fileTabContentFillsRegion: pluginPanelTabFillsRegion( + activePluginPanelTab, + pluginThreadPanelActions, + ), splitPanelStateId: thread.id, splitTabModels: syncedOrderedSecondaryFileTabs, renderSplitTabContent, splitTabContentFillsRegion: (tab) => tab.kind === "plugin-panel" && - (tab.fileOpenerOwner !== undefined || - pluginThreadPanelActions.find( - (candidate) => - candidate.pluginId === tab.pluginId && - candidate.id === tab.actionId, - )?.layout === "flush"), + pluginPanelTabFillsRegion(tab, pluginThreadPanelActions), renderBrowserDeck, isBrowserTabActive, isOpen: isSecondaryPanelOpen, From 1fdf488eef590824debb21e76868639435a5cfdf Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 20 Aug 2026 11:33:09 -0700 Subject: [PATCH 11/18] Share plugin page full-region panel policy --- .../plugin/PluginPanelRightPanelHost.test.tsx | 102 +++++++++++++++++- .../plugin/PluginPanelRightPanelHost.tsx | 10 +- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx index 364d8ca9f9..dc30df8e88 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx @@ -15,6 +15,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { createEmptyFixedPanelTabsState, + createPluginPanelFixedPanelTab, createTerminalFixedPanelTab, getFixedPanelTabsStateStorageKey, serializeFixedPanelTabsState, @@ -39,6 +40,27 @@ interface TestFixedTabRegistration { layout?: "padded" | "flush"; } +interface TestFileOpenerRegistration { + id: string; + title: string; + extensions: string[]; + component: () => ReactNode; + pluginId: string; + generation: number; +} + +interface TestNewThreadPanelActionRegistration { + id: string; + title: string; + component: (props: { + projectId: string | null; + params: import("@get-bb/plugin-sdk").JsonValue | null; + }) => ReactNode; + layout?: "padded" | "flush"; + pluginId: string; + generation: number; +} + const browserState = vi.hoisted(() => ({ available: false })); const createTerminal = vi.hoisted(() => vi.fn()); const threadTabsApi = vi.hoisted(() => ({ @@ -84,6 +106,8 @@ const terminalQueryState = vi.hoisted(() => ({ const fixedTabState = vi.hoisted(() => ({ panelRegistered: true, registrations: [] as TestFixedTabRegistration[], + fileOpeners: [] as TestFileOpenerRegistration[], + newThreadPanelActions: [] as TestNewThreadPanelActionRegistration[], })); const hostState = vi.hoisted(() => ({ hosts: [ @@ -118,7 +142,8 @@ vi.mock("@/components/commands/AppCommandProvider", () => ({ vi.mock("@/lib/plugin-slots", () => ({ usePluginSlots: () => ({ - fileOpeners: [], + fileOpeners: fixedTabState.fileOpeners, + newThreadPanelActions: fixedTabState.newThreadPanelActions, navPanels: fixedTabState.panelRegistered ? [ { @@ -247,6 +272,7 @@ vi.mock("@/components/secondary-panel/ThreadSecondaryPanel", () => ({ browserDeck, fileTabs, fileTabContent, + fileTabContentFillsRegion, fixedTabs, fixedTabContent, onClose, @@ -261,6 +287,7 @@ vi.mock("@/components/secondary-panel/ThreadSecondaryPanel", () => ({ onSelect: () => void; }>; fileTabContent: ReactNode; + fileTabContentFillsRegion?: boolean; fixedTabs: Array<{ tab: { id: string }; title: string; @@ -273,6 +300,9 @@ vi.mock("@/components/secondary-panel/ThreadSecondaryPanel", () => ({ }) => (