From 73d634f1f6c49220f4dbf3c5ae5dc152649d1eee Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 4 Aug 2026 11:18:33 -0700 Subject: [PATCH 1/4] Unify Docs sidebar navigation --- official-plugins/docs/app.test.tsx | 40 +++-- official-plugins/docs/app.tsx | 252 ++++++++++++++--------------- 2 files changed, 148 insertions(+), 144 deletions(-) diff --git a/official-plugins/docs/app.test.tsx b/official-plugins/docs/app.test.tsx index 1422b18c6e..b2190d2f99 100644 --- a/official-plugins/docs/app.test.tsx +++ b/official-plugins/docs/app.test.tsx @@ -1,5 +1,11 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + waitFor, + within, +} from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loadPluginApp, renderSlot } from "@bb/plugin-sdk/testing/app"; @@ -86,8 +92,8 @@ describe("Docs nav panel", () => { id: "docs", title: "Docs", path: "docs", - headerContent: expect.any(Function), }); + expect(app.navPanels[0]?.headerContent).toBeUndefined(); expect(app.messageDirectives).toHaveLength(1); expect(app.messageDirectives[0]?.id).toBe("docs"); expect(app.threadPanelActions[0]).toMatchObject({ @@ -101,7 +107,7 @@ describe("Docs nav panel", () => { }); }); - it("moves the sidebar toggle into the shared panel header", async () => { + it("keeps the right-sidebar navigation together across panel layouts", async () => { const panel = app.navPanels[0]!; const slot = renderSlot( panel, @@ -110,21 +116,25 @@ describe("Docs nav panel", () => { ); await slot.findByText("Select a note or HTML page."); - const HeaderContent = panel.headerContent!; - const header = render(); - await waitFor(() => { - expect( - slot.container.querySelector('[aria-label="Collapse notes sidebar"]'), - ).toBeNull(); + const navigation = slot.getByRole("navigation", { + name: "Notes sidebar", }); - - fireEvent.click( - header.getByRole("button", { name: "Collapse notes sidebar" }), - ); - expect(slot.container.querySelector("aside")?.style.width).toBe("0px"); + expect( + within(navigation).getByRole("button", { name: "Search notes" }), + ).toBeTruthy(); + expect( + within(navigation).getByRole("button", { name: "New note" }), + ).toBeTruthy(); + expect( + within(navigation).getByRole("button", { name: "New folder" }), + ).toBeTruthy(); fireEvent.click( - header.getByRole("button", { name: "Expand notes sidebar" }), + within(navigation).getByRole("button", { + name: "Collapse notes sidebar", + }), ); + expect(slot.container.querySelector("aside")?.style.width).toBe("40px"); + fireEvent.click(slot.getByRole("button", { name: "Expand notes sidebar" })); expect(slot.container.querySelector("aside")?.style.width).toBe("288px"); }); diff --git a/official-plugins/docs/app.tsx b/official-plugins/docs/app.tsx index b98d7b19a5..510e440abd 100644 --- a/official-plugins/docs/app.tsx +++ b/official-plugins/docs/app.tsx @@ -1243,14 +1243,12 @@ function orderEntries( const SIDEBAR_AUTO_COLLAPSE_PANE_WIDTH = 640; interface NotesSidebarState { - headerMounted: boolean; paneNarrow: boolean; userCollapsed: boolean | null; } interface NotesSidebarStore { state: NotesSidebarState; - headerMounts: number; viewMounts: number; listeners: Set<() => void>; } @@ -1267,11 +1265,9 @@ function getNotesSidebarStore(key: string): NotesSidebarStore { if (existing) return existing; const store: NotesSidebarStore = { state: { - headerMounted: false, paneNarrow: false, userCollapsed: null, }, - headerMounts: 0, viewMounts: 0, listeners: new Set(), }; @@ -1285,7 +1281,6 @@ function updateNotesSidebarState( ): void { const next = { ...store.state, ...patch }; if ( - next.headerMounted === store.state.headerMounted && next.paneNarrow === store.state.paneNarrow && next.userCollapsed === store.state.userCollapsed ) { @@ -1312,34 +1307,111 @@ function useNotesSidebarState(key: string): { return { state, store }; } -function NotesPanelHeader({ subPath }: PluginNavPanelProps) { - const { state: sidebar, store } = useNotesSidebarState( - notesSidebarKey(subPath), - ); - const collapsed = sidebar.userCollapsed ?? sidebar.paneNarrow; - useLayoutEffect(() => { - store.headerMounts += 1; - updateNotesSidebarState(store, { headerMounted: true }); - return () => { - store.headerMounts = Math.max(0, store.headerMounts - 1); - if (store.headerMounts === 0) { - updateNotesSidebarState(store, { headerMounted: false }); - } +type NotesSidebarNavigationProps = + | { + collapsed: true; + onCollapsedChange(collapsed: boolean): void; + } + | { + collapsed: false; + query: string; + searchOpen: boolean; + onQueryChange(value: string): void; + onSearchOpenChange(open: boolean): void; + onNewNote(): void; + onNewFolder(): void; + onCollapsedChange(collapsed: boolean): void; }; - }, [store]); + +function NotesSidebarNavigation(props: NotesSidebarNavigationProps) { + const { collapsed, onCollapsedChange } = props; return ( - + {!collapsed && props.searchOpen ? ( + <> +
+ + props.onQueryChange(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape") return; + props.onQueryChange(""); + props.onSearchOpenChange(false); + }} + placeholder="Search this vault" + /> +
+ + + ) : null} + {!collapsed && !props.searchOpen ? ( + <> + + + + + + ) : null} + + ); } @@ -1589,26 +1661,15 @@ function Tree({ return ( ); } @@ -1619,85 +1680,19 @@ function Tree({ className="relative order-2 flex shrink-0 flex-col border-l border-border bg-muted/20" style={{ width: sidebarWidth }} > -
- {searchOpen ? ( - <> -
- - setQuery(event.target.value)} - onKeyDown={(event) => { - if (event.key !== "Escape") return; - setQuery(""); - setSearchOpen(false); - }} - placeholder="Search this vault" - /> -
- - - ) : ( - <> - - - - - - )} - {!sidebar.headerMounted ? ( - - ) : null} +
+ + updateNotesSidebarState(sidebarStore, { userCollapsed }) + } + /> {draggingPath && dirname(draggingPath) ? ( + ); +} + +function NotesPanelHeader({ subPath }: PluginNavPanelProps) { + const { state: sidebar, store } = useNotesSidebarState( + notesSidebarKey(subPath), + ); + const collapsed = sidebar.userCollapsed ?? sidebar.paneNarrow; + useLayoutEffect(() => { + store.headerMounts += 1; + updateNotesSidebarState(store, { headerMounted: true }); + return () => { + store.headerMounts = Math.max(0, store.headerMounts - 1); + if (store.headerMounts === 0) { + updateNotesSidebarState(store, { headerMounted: false }); + } }; + }, [store]); + return ( + + updateNotesSidebarState(store, { userCollapsed }) + } + /> + ); +} + +interface NotesSidebarNavigationProps { + query: string; + searchOpen: boolean; + onQueryChange(value: string): void; + onSearchOpenChange(open: boolean): void; + onNewNote(): void; + onNewFolder(): void; +} function NotesSidebarNavigation(props: NotesSidebarNavigationProps) { - const { collapsed, onCollapsedChange } = props; return ( ); } @@ -1661,15 +1689,21 @@ function Tree({ return ( ); } @@ -1682,17 +1716,21 @@ function Tree({ >
- updateNotesSidebarState(sidebarStore, { userCollapsed }) - } /> + {!sidebar.headerMounted ? ( + + updateNotesSidebarState(sidebarStore, { userCollapsed }) + } + /> + ) : null} {draggingPath && dirname(draggingPath) ? (
); } @@ -495,68 +458,6 @@ function SortableFileTab({ ); } -interface TabStripScrollChevronProps { - direction: "left" | "right"; - canScroll: boolean; - className: string | null; - onClick: () => void; -} - -function TabStripScrollChevron({ - direction, - canScroll, - className, - onClick, -}: TabStripScrollChevronProps) { - return ( - - ); -} - function FileTab({ tab, activeTreatment, diff --git a/apps/app/src/components/tools/Automations.stories.tsx b/apps/app/src/components/tools/Automations.stories.tsx index 24c9e6fb77..c58e3e3a1a 100644 --- a/apps/app/src/components/tools/Automations.stories.tsx +++ b/apps/app/src/components/tools/Automations.stories.tsx @@ -522,9 +522,11 @@ function AutomationDetail({ actionPending={false} editing={false} executionOptions={executionOptions} + permissionModes={["accept-edits", "auto", "full"]} executionOptionsError={null} onToggle={noop} onEdit={noop} + onCancelEdit={noop} onUpdateAgent={async () => {}} onRunNow={noop} onDelete={noop} diff --git a/apps/app/src/components/tools/SkillDetailView.tsx b/apps/app/src/components/tools/SkillDetailView.tsx index 9812832078..d8fe9dfb99 100644 --- a/apps/app/src/components/tools/SkillDetailView.tsx +++ b/apps/app/src/components/tools/SkillDetailView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { formatHomePathForDisplay } from "@bb/shared-ui/lib/utils"; @@ -129,6 +129,10 @@ function getSkillDirectoryPath(path: string): string { return path.replace(/[\\/]SKILL\.md$/i, ""); } +const SKILL_PAGE_WHEEL_THRESHOLD_PX = 40; +const SKILL_PAGE_WHEEL_GESTURE_RESET_MS = 160; +const WHEEL_LINE_HEIGHT_PX = 16; + function SkillFileList({ files, selectedPath, @@ -173,6 +177,11 @@ function PagedSkillContent({ pageHeight: 0, pageCount: 1, }); + const pageRef = useRef(page); + const pageCountRef = useRef(measurement.pageCount); + const wheelDeltaRef = useRef(0); + const wheelPageChangedRef = useRef(false); + const wheelResetTimeoutRef = useRef(null); useEffect(() => { if (viewport === null || pages === null) return; @@ -209,6 +218,84 @@ function PagedSkillContent({ }, [pages, viewport]); const safePage = Math.min(page, measurement.pageCount - 1); + pageRef.current = safePage; + pageCountRef.current = measurement.pageCount; + + useEffect(() => { + if (viewport === null) return; + const viewportElement = viewport; + + const resetWheelGesture = () => { + wheelDeltaRef.current = 0; + wheelPageChangedRef.current = false; + if (wheelResetTimeoutRef.current !== null) { + window.clearTimeout(wheelResetTimeoutRef.current); + wheelResetTimeoutRef.current = null; + } + }; + + const refreshWheelGestureReset = () => { + if (wheelResetTimeoutRef.current !== null) { + window.clearTimeout(wheelResetTimeoutRef.current); + } + wheelResetTimeoutRef.current = window.setTimeout( + resetWheelGesture, + SKILL_PAGE_WHEEL_GESTURE_RESET_MS, + ); + }; + + const handleWheel = (event: WheelEvent) => { + if ( + event.ctrlKey || + event.deltaY === 0 || + Math.abs(event.deltaX) >= Math.abs(event.deltaY) + ) { + return; + } + + refreshWheelGestureReset(); + const direction = event.deltaY > 0 ? 1 : -1; + const currentPage = pageRef.current; + const pageCount = pageCountRef.current; + const nextPage = currentPage + direction; + if (nextPage < 0 || nextPage >= pageCount) { + if (!wheelPageChangedRef.current) { + wheelDeltaRef.current = 0; + } + return; + } + + event.preventDefault(); + if (wheelPageChangedRef.current) return; + if ( + wheelDeltaRef.current !== 0 && + Math.sign(wheelDeltaRef.current) !== direction + ) { + wheelDeltaRef.current = 0; + } + const normalizedDelta = + event.deltaMode === 1 + ? event.deltaY * WHEEL_LINE_HEIGHT_PX + : event.deltaMode === 2 + ? event.deltaY * viewportElement.clientHeight + : event.deltaY; + wheelDeltaRef.current += normalizedDelta; + if (Math.abs(wheelDeltaRef.current) < SKILL_PAGE_WHEEL_THRESHOLD_PX) { + return; + } + + wheelPageChangedRef.current = true; + wheelDeltaRef.current = 0; + pageRef.current = nextPage; + setPage(nextPage); + }; + + viewportElement.addEventListener("wheel", handleWheel, { passive: false }); + return () => { + viewportElement.removeEventListener("wheel", handleWheel); + resetWheelGesture(); + }; + }, [viewport]); return (
diff --git a/apps/app/src/components/tools/SkillsCollection.tsx b/apps/app/src/components/tools/SkillsCollection.tsx index 5dea01ebb2..248767f2e4 100644 --- a/apps/app/src/components/tools/SkillsCollection.tsx +++ b/apps/app/src/components/tools/SkillsCollection.tsx @@ -79,7 +79,7 @@ function skillSourceFilterId( } function skillSourceFilterLabel(source: ResourceSkillSourceFilter): string { - return source === "bb-official" ? "bb official" : "Included in plugin"; + return source === "bb-official" ? "bb official" : "Plugin"; } function isResourceSkillSourceFilter( diff --git a/apps/app/src/components/tools/detail-page-recipes.test.tsx b/apps/app/src/components/tools/detail-page-recipes.test.tsx index d11e451dd2..b103de4d39 100644 --- a/apps/app/src/components/tools/detail-page-recipes.test.tsx +++ b/apps/app/src/components/tools/detail-page-recipes.test.tsx @@ -245,6 +245,7 @@ describe("Plugin detail recipe", () => { expect(screen.getByText(item).className).toContain("text-xs"); } const skillName = screen.getByText("review"); + expect(skillName.closest("th")?.className).toContain("items-center"); expect(skillName.parentElement?.className).toContain("items-center"); expect(skillName.previousElementSibling?.className).not.toContain("mt-px"); }); @@ -269,15 +270,16 @@ describe("Plugin detail recipe", () => { name: "Show full description", }); expect(disclosure.getAttribute("aria-expanded")).toBe("false"); + expect(disclosure.className).toContain("text-subtle-foreground"); fireEvent.click(disclosure); expect(detail.className).not.toContain("line-clamp-3"); - expect( - screen - .getByRole("button", { name: "Show less" }) - .getAttribute("aria-expanded"), - ).toBe("true"); + const collapseDisclosure = screen.getByRole("button", { + name: "Show less", + }); + expect(collapseDisclosure.getAttribute("aria-expanded")).toBe("true"); + expect(collapseDisclosure.className).toContain("text-subtle-foreground"); expect(container.textContent).toContain(description); }); @@ -616,6 +618,70 @@ describe("Skill detail recipe", () => { expect(content?.style.transform).toBe("translateY(-540px)"); expect(next.getAttribute("disabled")).not.toBeNull(); }); + + it("pages once per vertical wheel or trackpad gesture", () => { + vi.useFakeTimers(); + try { + const { container } = renderSkill(["/skills/writing-voice/SKILL.md"]); + const viewport = container.querySelector( + "[data-skill-content-viewport]", + ); + const content = container.querySelector( + "[data-skill-content-pages]", + ); + expect(viewport).not.toBeNull(); + expect(content).not.toBeNull(); + + Object.defineProperty(viewport, "clientHeight", { + configurable: true, + value: 240, + }); + Object.defineProperty(content, "scrollHeight", { + configurable: true, + value: 720, + }); + act(() => window.dispatchEvent(new Event("resize"))); + + const pagination = screen.getByRole("navigation", { + name: "Skill content pagination", + }); + fireEvent.wheel(viewport!, { deltaY: -100 }); + expect(pagination.textContent).toContain("Page 1 of 3"); + + // Trackpads emit several small pixel deltas. Accumulate them, then move + // exactly one page for the gesture even if momentum events continue. + fireEvent.wheel(viewport!, { deltaY: 24 }); + expect(pagination.textContent).toContain("Page 1 of 3"); + fireEvent.wheel(viewport!, { deltaY: 24 }); + expect(pagination.textContent).toContain("Page 2 of 3"); + fireEvent.wheel(viewport!, { deltaY: 100 }); + expect(pagination.textContent).toContain("Page 2 of 3"); + + act(() => { + vi.advanceTimersByTime(161); + }); + // Line-mode wheel input is normalized to pixels and uses the same + // threshold and one-page-per-gesture behavior. + fireEvent.wheel(viewport!, { deltaY: 3, deltaMode: 1 }); + expect(pagination.textContent).toContain("Page 3 of 3"); + + // Momentum can keep moving toward the boundary, then briefly rebound in + // the opposite direction. Both events are still part of the gesture that + // moved from page 2 to page 3, so the rebound must not navigate back. + fireEvent.wheel(viewport!, { deltaY: 100 }); + expect(pagination.textContent).toContain("Page 3 of 3"); + fireEvent.wheel(viewport!, { deltaY: -40 }); + expect(pagination.textContent).toContain("Page 3 of 3"); + + act(() => { + vi.advanceTimersByTime(161); + }); + fireEvent.wheel(viewport!, { deltaY: -40 }); + expect(pagination.textContent).toContain("Page 2 of 3"); + } finally { + vi.useRealTimers(); + } + }); }); const AUTOMATION: AutomationResponse = { @@ -662,12 +728,22 @@ const AUTOMATION_EXECUTION_OPTIONS: AutomationExecutionOptionsResponse = { type TestAutomationDetailProps = Omit< ComponentProps, - "editing" | "executionOptions" | "executionOptionsError" | "onUpdateAgent" + | "editing" + | "executionOptions" + | "executionOptionsError" + | "permissionModes" + | "onCancelEdit" + | "onUpdateAgent" > & Partial< Pick< ComponentProps, - "editing" | "executionOptions" | "executionOptionsError" | "onUpdateAgent" + | "editing" + | "executionOptions" + | "executionOptionsError" + | "permissionModes" + | "onCancelEdit" + | "onUpdateAgent" > >; @@ -675,6 +751,8 @@ function AutomationDetailView({ editing = false, executionOptions = AUTOMATION_EXECUTION_OPTIONS, executionOptionsError = null, + permissionModes = AUTOMATION_EXECUTION_OPTIONS.permissionModes, + onCancelEdit = () => {}, onUpdateAgent = async () => {}, ...props }: TestAutomationDetailProps) { @@ -684,6 +762,8 @@ function AutomationDetailView({ editing={editing} executionOptions={executionOptions} executionOptionsError={executionOptionsError} + permissionModes={permissionModes} + onCancelEdit={onCancelEdit} onUpdateAgent={onUpdateAgent} /> ); @@ -731,6 +811,7 @@ describe("Automation detail recipe", () => { }} onToggle={() => {}} onEdit={() => setEditing(true)} + onCancelEdit={() => setEditing(false)} onRunNow={() => {}} onDelete={() => {}} onOpenThread={() => {}} @@ -897,18 +978,53 @@ describe("Automation detail recipe", () => { expect( container.querySelector('[data-automation-provider-icon="claude"] svg'), ).not.toBeNull(); + expect( + container.querySelector( + '[data-automation-provider-icon="claude"] svg.block', + ), + ).not.toBeNull(); const savePrompt = screen.getByRole("button", { name: "Save Prompt" }); expect(promptPanel.contains(savePrompt)).toBe(true); + expect(savePrompt.querySelector('[data-icon="Check"]')).not.toBeNull(); expect((savePrompt as HTMLButtonElement).disabled).toBe(true); - fireEvent.change(promptContent, { + const cancelEditing = screen.getByRole("button", { name: "Cancel" }); + expect((cancelEditing as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(cancelEditing); + expect( + await screen.findByRole("textbox", { name: "Saved prompt" }), + ).toBeTruthy(); + expect(updateAgent).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Edit prompt" })); + const reopenedPrompt = screen.getByRole("textbox", { + name: "Automation prompt", + }) as HTMLTextAreaElement; + const reopenedPanel = reopenedPrompt.closest("form") as HTMLElement; + const reopenedModelSelector = reopenedPanel.querySelector( + '[data-automation-selector="Provider and model"]', + ) as HTMLButtonElement; + const reopenedAccessSelector = container.querySelector( + '[data-automation-selector="Permission mode"]', + ) as HTMLButtonElement; + const reopenedSavePrompt = screen.getByRole("button", { + name: "Save Prompt", + }); + fireEvent.change(reopenedPrompt, { target: { value: "Summarize the last two days." }, }); - fireEvent.keyDown(modelSelector, { key: "Enter" }); + fireEvent.keyDown(reopenedModelSelector, { key: "Enter" }); + const modelOptions = await screen.findByRole("listbox"); + expect(modelOptions.className).toContain("w-max"); + expect(modelOptions.className).toContain("min-w-0"); fireEvent.click(await screen.findByRole("option", { name: "Sonnet 5" })); - fireEvent.keyDown(accessSelector, { key: "Enter" }); + fireEvent.keyDown(reopenedAccessSelector, { key: "Enter" }); fireEvent.click(await screen.findByRole("option", { name: "Full Access" })); - expect((savePrompt as HTMLButtonElement).disabled).toBe(false); - fireEvent.click(savePrompt); + expect((reopenedSavePrompt as HTMLButtonElement).disabled).toBe(false); + expect( + (screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + fireEvent.click(reopenedSavePrompt); expect(updateAgent).toHaveBeenCalledWith({ prompt: "Summarize the last two days.", model: "claude-sonnet-5", @@ -922,6 +1038,44 @@ describe("Automation detail recipe", () => { ).toBeNull(); }); + it("does not make permission editing wait for model discovery", () => { + const { container } = render( + + {}, + retry: () => {}, + }} + actionPending={false} + editing + executionOptions={null} + permissionModes={["accept-edits", "auto", "full"]} + onToggle={() => {}} + onEdit={() => {}} + onRunNow={() => {}} + onDelete={() => {}} + onOpenThread={() => {}} + /> + , + ); + + const permissionSelector = container.querySelector( + '[data-automation-selector="Permission mode"]', + ) as HTMLButtonElement; + const modelSelector = container.querySelector( + '[data-automation-selector="Provider and model"]', + ) as HTMLButtonElement; + expect(permissionSelector.disabled).toBe(false); + expect(modelSelector.disabled).toBe(true); + }); + it("uses the composer metadata treatment without inventing reasoning", () => { const { container } = render( diff --git a/apps/app/src/components/tools/plugin-detail-table.tsx b/apps/app/src/components/tools/plugin-detail-table.tsx index 96c925ffc2..bd67a310f5 100644 --- a/apps/app/src/components/tools/plugin-detail-table.tsx +++ b/apps/app/src/components/tools/plugin-detail-table.tsx @@ -166,7 +166,7 @@ export function PluginDetailRow({ className={cn( CELL, PLUGIN_DETAIL_HEADER_CELL_CLASS, - "text-left font-normal", + "flex items-center text-left font-normal", hasDetail ? "border-r border-border pl-4 pr-2" : "px-4", )} colSpan={hasDetail ? undefined : 2} @@ -210,7 +210,7 @@ export function PluginDetailRow({ type="button" aria-expanded={expanded} aria-controls={detailId} - className="mt-2 rounded-sm text-xs font-medium text-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + className="mt-2 rounded-sm text-xs font-medium text-subtle-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" onClick={() => setExpanded((current) => !current)} > {expanded ? "Show less" : "Show full description"} diff --git a/apps/app/src/views/SkillsView.test.tsx b/apps/app/src/views/SkillsView.test.tsx index 614ea69c85..595c4956ad 100644 --- a/apps/app/src/views/SkillsView.test.tsx +++ b/apps/app/src/views/SkillsView.test.tsx @@ -319,12 +319,10 @@ describe("SkillsOverview", () => { ).toBe("true"); expect( screen - .getByRole("menuitemcheckbox", { name: "Included in plugin" }) + .getByRole("menuitemcheckbox", { name: "Plugin" }) .getAttribute("aria-checked"), ).toBe("false"); - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "Included in plugin" }), - ); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Plugin" })); expect(await screen.findByText("automations")).toBeTruthy(); expect( @@ -332,14 +330,12 @@ describe("SkillsOverview", () => { "automations is included with Automations (bb plugin)", ).textContent, ).toBe("Included"); - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "Included in plugin" }), - ); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Plugin" })); expect(screen.queryByText("automations")).toBeNull(); expect(screen.getByText("official-skill")).toBeTruthy(); }); - it("toggles BB official independently from Included", async () => { + it("toggles BB official independently from Plugin", async () => { renderDom( { ); fireEvent.pointerDown(screen.getByRole("button", { name: "bb official" })); - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "Included in plugin" }), - ); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Plugin" })); fireEvent.click( screen.getByRole("menuitemcheckbox", { name: "bb official" }), ); diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx index 0691b64f11..4a0fdae993 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx @@ -419,6 +419,25 @@ function pluginSplitLayout(): SplitLayout { }; } +function twoPluginSplitLayout(): SplitLayout { + return { + root: { + type: "split", + dir: "row", + sizes: [0.5, 0.5], + children: [ + { + type: "pane", + paneId: "pane-automations", + content: pluginContent("automations"), + }, + { type: "pane", paneId: "pane-docs", content: docsContent }, + ], + }, + focusedPaneId: "pane-docs", + }; +} + function threadPath(threadId: string): string { return `/threads/${threadId}`; } @@ -1233,17 +1252,15 @@ describe("SplitThreadArea", () => { expect(showPanel.hasAttribute("disabled")).toBe(false); fireEvent.click(showPanel); + expect(await screen.findByTestId("hosted-new-thread-panel")).toBeTruthy(); expect( - await screen.findByTestId("hosted-new-thread-panel"), - ).toBeTruthy(); - expect( - screen.getByRole("button", { name: "Hide right panel" }).getAttribute( - "aria-expanded", - ), + screen + .getByRole("button", { name: "Hide right panel" }) + .getAttribute("aria-expanded"), ).toBe("true"); }); - it("suppresses and disables the panel on a pane with no panel support", async () => { + it("omits app panel and full-screen controls from plugin panes", async () => { const layout = pluginSplitLayout(); layout.focusedPaneId = "pane-1"; renderSplitArea({ @@ -1264,23 +1281,24 @@ describe("SplitThreadArea", () => { throw new Error("Expected plugin split pane"); } - // Focusing the plugin pane hides the unavailable panel and disables its - // disclosure without discarding the window-level open state. + // Focusing the plugin pane hides the app panel without layering disabled + // app controls over the plugin's own header and right panel. fireEvent.pointerDown(pluginPane); - const unavailableToggle = await screen.findByRole("button", { - name: "Right panel unavailable", - }); - expect(unavailableToggle.hasAttribute("disabled")).toBe(true); - expect(unavailableToggle.getAttribute("aria-expanded")).toBe("false"); + await waitFor(() => + expect(screen.queryByTestId("split-workspace-panel-toggle")).toBeNull(), + ); + expect( + document.getElementById("split-workspace-empty-secondary-panel"), + ).toBeNull(); + expect( + document.getElementById("split-workspace-empty-secondary-panel-handle"), + ).toBeNull(); + expect( + pluginPane.querySelector('button[aria-label*="Full Screen"]'), + ).toBeNull(); expect( screen.queryByTestId("split-workspace-empty-panel-state"), ).toBeNull(); - const emptyPanelHandle = document.getElementById( - "split-workspace-empty-secondary-panel-handle", - ); - expect(emptyPanelHandle?.classList).toContain("w-0"); - expect(emptyPanelHandle?.classList).toContain("pointer-events-none"); - // Refocusing the thread pane restores the remembered open panel. fireEvent.pointerDown(screen.getByTestId("pane-thr-a")); const restoredOpenToggle = await screen.findByRole("button", { @@ -1293,6 +1311,82 @@ describe("SplitThreadArea", () => { ).toBeNull(); }); + it("preserves plugin-owned right panels with and without a plugin split", async () => { + setPluginSlotRegistrations("test-plugin", { + homepageSections: [], + settingsSections: [], + navPanels: [ + { + id: "automations", + title: "Automations", + icon: "Clock", + path: "automations", + component: () =>
Automations content
, + }, + ], + threadPanelActions: [], + pendingInteractions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + }); + setPluginSlotRegistrations("docs", { + homepageSections: [], + settingsSections: [], + navPanels: [ + { + id: "docs", + title: "Docs", + icon: "FileText", + path: "docs", + component: () =>
Docs content with notes sidebar
, + headerContent: () => ( + + ), + }, + ], + threadPanelActions: [], + pendingInteractions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + }); + + renderSplitArea({ + path: "/plugins/docs/docs", + layout: twoPluginSplitLayout(), + routeContent: docsContent, + }); + + expect(await screen.findByText("Automations content")).toBeTruthy(); + const docsPanelContent = screen.getByText( + "Docs content with notes sidebar", + ); + expect(docsPanelContent).toBeTruthy(); + expect(docsPanelContent.closest(".isolate")).not.toBeNull(); + expect( + screen.getByRole("button", { name: "Collapse notes sidebar" }), + ).toBeTruthy(); + expect(screen.queryByTestId("split-workspace-panel-toggle")).toBeNull(); + expect(screen.queryByRole("button", { name: /Full Screen/ })).toBeNull(); + + fireEvent.click(screen.getAllByRole("button", { name: "Close pane" })[0]!); + + await waitFor(() => + expect(screen.queryByText("Automations content")).toBeNull(), + ); + expect( + screen + .getByText("Docs content with notes sidebar") + .closest(".isolate"), + ).toBeNull(); + expect( + screen.getByRole("button", { name: "Collapse notes sidebar" }), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Close pane" })).toBeNull(); + expect(screen.queryByTestId("split-workspace-panel-toggle")).toBeNull(); + }); + it("mounts both panes with independent, threadId-keyed drafts", async () => { renderSplitArea({ path: threadPath("thr-b"), @@ -1504,7 +1598,7 @@ describe("SplitThreadArea", () => { } }); - it("reserves collapsed window-left chrome only for the structural top-left pane", async () => { + it("reserves collapsed window-left chrome only for the structural top-left plugin pane", async () => { const desktopInfo: BbDesktopInfo = { lastCheckedAt: null, latestVersion: null, @@ -1550,24 +1644,7 @@ describe("SplitThreadArea", () => { expect((await contentRow(path))?.className).not.toContain("pl-[104px]"); } - fireEvent.click(screen.getAllByRole("button", { name: /Full Screen/ })[3]!); - await waitFor(() => - expect(contentRow("bottom-right")).resolves.toHaveProperty( - "className", - expect.stringContaining("pl-[104px]"), - ), - ); - expect((await contentRow("top-left"))?.className).not.toContain( - "pl-[104px]", - ); - - fireEvent.click(screen.getByRole("button", { name: /Exit Full Screen/ })); - await waitFor(() => - expect(contentRow("top-left")).resolves.toHaveProperty( - "className", - expect.stringContaining("pl-[104px]"), - ), - ); + expect(screen.queryByRole("button", { name: /Full Screen/ })).toBeNull(); }); it("assigns exactly one top-left owner through eight-pane structural changes", async () => { diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.tsx index 0ae4077a1c..d12ddc3076 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.tsx @@ -266,11 +266,16 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { : null); const panes = layout === null ? [] : listPanes(layout.root); const isSplitActive = threadSplitsEnabled && !isCompact && panes.length > 1; + const maximizedPane = + layout !== null && maximizedPaneId !== null + ? findPane(layout.root, maximizedPaneId) + : null; const effectiveMaximizedPaneId = layout !== null && countPanes(layout.root) > 1 && maximizedPaneId !== null && - findPane(layout.root, maximizedPaneId) !== null + maximizedPane !== null && + maximizedPane.content.kind !== "plugin-panel" ? maximizedPaneId : null; const { @@ -328,7 +333,8 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { if ( layout === null || countPanes(layout.root) < 2 || - findPane(layout.root, maximizedPaneId) === null + maximizedPane === null || + maximizedPane.content.kind === "plugin-panel" ) { setMaximizedPaneId(null); return; @@ -336,7 +342,7 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { if (layout.focusedPaneId !== maximizedPaneId) { setMaximizedPaneId(layout.focusedPaneId); } - }, [layout, maximizedPaneId, setMaximizedPaneId]); + }, [layout, maximizedPane, maximizedPaneId, setMaximizedPaneId]); // Content navigation inside a pane pushes history like the page surface does // today. replacePaneContent focuses the pane, so the pushed URL matches it. @@ -397,10 +403,12 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { const toggleMaximizePane = useCallback( (paneId: string) => { const current = store.get(splitLayoutAtom); + const pane = current === null ? null : findPane(current.root, paneId); if ( current === null || countPanes(current.root) < 2 || - findPane(current.root, paneId) === null + pane === null || + pane.content.kind === "plugin-panel" ) { return; } @@ -783,11 +791,22 @@ function SplitTree(props: SplitTreeProps) { isFocused={isFocused} isSplitPane secondaryPanelRegistry={props.secondaryPanelRegistry} - reservesWindowPanelToggle={isMaximized || (isTopRow && isRightEdge)} + reservesWindowPanelToggle={ + node.content.kind !== "plugin-panel" && + (isMaximized || (isTopRow && isRightEdge)) + } onRequestClose={() => props.onClosePane(node.paneId)} isMaximized={isMaximized} - onToggleMaximize={() => props.onToggleMaximizePane(node.paneId)} - onMoveToSide={(side) => props.onMovePaneToSide(node.paneId, side)} + onToggleMaximize={ + node.content.kind === "plugin-panel" + ? null + : () => props.onToggleMaximizePane(node.paneId) + } + onMoveToSide={ + node.content.kind === "plugin-panel" + ? undefined + : (side) => props.onMovePaneToSide(node.paneId, side) + } isBoundedPane isTopRow={isMaximized || isTopRow} ownsWindowTopLeft={ @@ -1051,7 +1070,7 @@ function NonThreadPaneContent({ subPath={content.kind === "plugin-panel" ? content.subPath : ""} /> ) : null} - + {content.kind === "plugin-panel" ? null : } {onRequestClose ? (
- {model === null ? ( - <> - {/* Keep a collapsed second panel registered with the group while - the focused plugin pane has no secondary-panel model. */} - - - - ) : ( - - {model.panel} - - )} + + {model.panel} +
diff --git a/plugins/automations/app.tsx b/plugins/automations/app.tsx index d8b8d7e520..b5ca1373c5 100644 --- a/plugins/automations/app.tsx +++ b/plugins/automations/app.tsx @@ -21,6 +21,7 @@ import { toast } from "sonner"; import type { AutomationResponse, AutomationExecutionOptionsResponse, + AutomationPermissionOptionsResponse, AgentExecutionUpdate, AutomationRunListResponse, AutomationRunResponse, @@ -247,37 +248,101 @@ function useAutomation(route: DetailRoute): { function useAutomationExecutionOptions( route: DetailRoute, enabled: boolean, + executionKey: string, ): { options: AutomationExecutionOptionsResponse | null; error: string | null; } { const rpc = useRpc(); const { projectId, automationId } = route; + const requestKey = `${projectId}:${automationId}:${executionKey}`; + const requestedKeyRef = useRef(null); const [state, setState] = useState<{ options: AutomationExecutionOptionsResponse | null; error: string | null; }>({ options: null, error: null }); useEffect(() => { - if (!enabled) { - setState({ options: null, error: null }); - return; - } + requestedKeyRef.current = null; + setState({ options: null, error: null }); + }, [requestKey]); + + useEffect(() => { + if (!enabled || requestedKeyRef.current === requestKey) return; + requestedKeyRef.current = requestKey; let active = true; + setState({ options: null, error: null }); rpc.call("automations_execution_options", { projectId, automationId }).then( (options) => { if (active) setState({ options, error: null }); }, (error: unknown) => { - if (active) setState({ options: null, error: errorText(error) }); + if (active) { + requestedKeyRef.current = null; + setState({ options: null, error: errorText(error) }); + } }, ); return () => { active = false; }; - }, [automationId, enabled, projectId, rpc]); + }, [automationId, enabled, projectId, requestKey, rpc]); + + return { options: state.options, error: state.error }; +} + +function useAutomationPermissionOptions( + route: DetailRoute, + enabled: boolean, + executionKey: string, +): { + options: AutomationPermissionOptionsResponse | null; + error: string | null; + retry: () => void; +} { + const rpc = useRpc(); + const { projectId, automationId } = route; + const requestKey = `${projectId}:${automationId}:${executionKey}`; + const requestedKeyRef = useRef(null); + const [attempt, setAttempt] = useState(0); + const [state, setState] = useState<{ + options: AutomationPermissionOptionsResponse | null; + error: string | null; + }>({ options: null, error: null }); + + useEffect(() => { + requestedKeyRef.current = null; + setState({ options: null, error: null }); + }, [requestKey]); + + useEffect(() => { + if (!enabled || requestedKeyRef.current === requestKey) return; + requestedKeyRef.current = requestKey; + let active = true; + setState({ options: null, error: null }); + rpc + .call("automations_permission_options", { projectId, automationId }) + .then( + (options) => { + if (active) setState({ options, error: null }); + }, + (error: unknown) => { + if (active) { + requestedKeyRef.current = null; + setState({ options: null, error: errorText(error) }); + } + }, + ); + return () => { + active = false; + }; + }, [attempt, automationId, enabled, projectId, requestKey, rpc]); - return state; + const retry = useCallback(() => { + requestedKeyRef.current = null; + setAttempt((current) => current + 1); + }, []); + return { options: state.options, error: state.error, retry }; } interface RunsState { @@ -538,11 +603,25 @@ function DetailView({ }) { const navigate = useBbNavigate(); const { automation, error, missing, refetch } = useAutomation(route); - const [editing, setEditing] = useState(initialEditing); + const [editingRequested, setEditingRequested] = useState(initialEditing); + const editingExecutionKey = + automation?.execution.mode === "agent" + ? JSON.stringify({ + providerId: automation.execution.providerId, + environment: automation.execution.environment, + }) + : "not-agent"; const executionOptionsState = useAutomationExecutionOptions( route, - editing && automation?.execution.mode === "agent", + editingRequested && automation?.execution.mode === "agent", + editingExecutionKey, ); + const permissionOptionsState = useAutomationPermissionOptions( + route, + editingRequested && automation?.execution.mode === "agent", + editingExecutionKey, + ); + const editing = editingRequested && permissionOptionsState.options !== null; const overviewState = useOverview(); const runsState = useRuns(route); const mutations = useMutations(); @@ -590,11 +669,14 @@ function DetailView({ const openEdit = useCallback(() => { if (automation === null) return; if (automation.execution.mode === "agent") { - setEditing(true); + if (permissionOptionsState.error !== null) { + permissionOptionsState.retry(); + } + setEditingRequested(true); return; } editViaThread(automation); - }, [automation, editViaThread]); + }, [automation, editViaThread, permissionOptionsState]); const updateAgent = useCallback( async (agent: AgentExecutionUpdate) => { @@ -602,7 +684,7 @@ function DetailView({ try { await mutations.update(route, agent); toast.success("Automation updated"); - setEditing(false); + setEditingRequested(false); refetch(); } catch (rpcError: unknown) { toast.error(`Failed to update automation: ${errorText(rpcError)}`); @@ -674,10 +756,14 @@ function DetailView({ runsState={runsState} actionPending={actionPending} executionOptions={executionOptionsState.options} - executionOptionsError={executionOptionsState.error} + executionOptionsError={ + executionOptionsState.error ?? permissionOptionsState.error + } + permissionModes={permissionOptionsState.options?.permissionModes ?? []} editing={editing} onToggle={(checked) => runAction(checked ? "resume" : "pause")} onEdit={openEdit} + onCancelEdit={() => setEditingRequested(false)} onUpdateAgent={updateAgent} onRunNow={() => runAction("run")} onDelete={() => setDeleteOpen(true)} diff --git a/plugins/automations/detail-view.tsx b/plugins/automations/detail-view.tsx index 58ab33ef71..c1d5df40be 100644 --- a/plugins/automations/detail-view.tsx +++ b/plugins/automations/detail-view.tsx @@ -7,6 +7,7 @@ import type { AutomationRunResponse, AutomationRunStatus, AgentExecutionUpdate, + PermissionMode, } from "./src/rpc-types"; import { AUTOMATION_PROMPT_MAX_LENGTH } from "./src/rpc-types"; import { Button } from "@bb/shared-ui/button"; @@ -80,9 +81,11 @@ export interface AutomationDetailViewProps { actionPending: boolean; executionOptions: AutomationExecutionOptionsResponse | null; executionOptionsError: string | null; + permissionModes: readonly PermissionMode[]; editing: boolean; onToggle: (enabled: boolean) => void; onEdit: () => void; + onCancelEdit: () => void; onUpdateAgent: (update: AgentExecutionUpdate) => Promise; onRunNow: () => void; onDelete: () => void; @@ -329,10 +332,12 @@ function AutomationSelector({ className={OPTION_CONTENT_CLASS_NAME} > {leading} - + + +
- + {options.map((option) => ( ; @@ -664,6 +671,8 @@ function AgentAutomationDefinition({ personalProject: boolean; projectContextLabel: string; pending: boolean; + permissionModes: readonly PermissionMode[]; + onCancel: () => void; onUpdate: (update: AgentExecutionUpdate) => Promise; }) { const [prompt, setPrompt] = useState(execution.prompt); @@ -696,12 +705,10 @@ function AgentAutomationDefinition({ label: formatAutomationModelLabel(model, execution.providerId), }); } - const permissionOptions = (options?.permissionModes ?? [permissionMode]).map( - (mode) => ({ - value: mode, - label: formatPermissionMode(mode), - }), - ); + const permissionOptions = permissionModes.map((mode) => ({ + value: mode, + label: formatPermissionMode(mode), + })); const promptBox = editing ? (
+ @@ -822,11 +840,9 @@ function AgentAutomationDefinition({ label="Permission mode" value={permissionMode} options={permissionOptions} - disabled={pending || options === null} + disabled={pending} onValueChange={(value) => { - const next = options?.permissionModes.find( - (mode) => mode === value, - ); + const next = permissionModes.find((mode) => mode === value); if (next !== undefined) setPermissionMode(next); }} className="h-6 shrink-0" @@ -857,7 +873,7 @@ function AgentAutomationDefinition({ )} {optionsError ? (

- Couldn't load model options. {optionsError} + Couldn't load editing options. {optionsError}

) : null} {editing ? promptFooter : null} @@ -872,9 +888,11 @@ export function AutomationDetailView({ actionPending, executionOptions, executionOptionsError, + permissionModes, editing, onToggle, onEdit, + onCancelEdit, onUpdateAgent, onRunNow, onDelete, @@ -983,8 +1001,10 @@ export function AutomationDetailView({ optionsError={executionOptionsError} editing={editing} pending={actionPending} + permissionModes={permissionModes} personalProject={personalProject} projectContextLabel={projectContextLabel} + onCancel={onCancelEdit} onUpdate={onUpdateAgent} /> ) : ( diff --git a/plugins/automations/lib/provider-icon.tsx b/plugins/automations/lib/provider-icon.tsx index 67e070d1fe..5d7d226f97 100644 --- a/plugins/automations/lib/provider-icon.tsx +++ b/plugins/automations/lib/provider-icon.tsx @@ -110,7 +110,7 @@ export function AutomationProviderIcon({ providerId }: { providerId: string }) { aria-hidden="true" className="inline-flex size-3.5 shrink-0 items-center justify-center text-muted-foreground" > - +
); } diff --git a/plugins/automations/src/cli.ts b/plugins/automations/src/cli.ts index 86e0b9ad70..4a4391a5d8 100644 --- a/plugins/automations/src/cli.ts +++ b/plugins/automations/src/cli.ts @@ -18,7 +18,10 @@ import type { ResolvedCreateAutomationInput, UpdateAutomationInput, } from "./rpc-types.js"; -import { resolvePermissionMode } from "./provider-permissions.js"; +import { + providerRoutingForEnvironment, + resolvePermissionMode, +} from "./provider-permissions.js"; import { AUTOMATION_SCRIPT_TIMEOUT_DEFAULT_MS, automationScriptInterpreterSchema, @@ -324,6 +327,7 @@ async function buildExecution( ); } validateAgentTargetOptions(args); + const environment = await buildAgentEnvironment(bb, args); return { mode: "agent", prompt, @@ -333,8 +337,9 @@ async function buildExecution( bb, provider, parsePermissionMode(flag(args, "permission-mode")), + providerRoutingForEnvironment(environment), ), - environment: await buildAgentEnvironment(bb, args), + environment, ...(flag(args, "target-thread") ? { targetThreadId: flag(args, "target-thread") } : {}), diff --git a/plugins/automations/src/provider-permissions.ts b/plugins/automations/src/provider-permissions.ts index 14686f3710..233efc04d7 100644 --- a/plugins/automations/src/provider-permissions.ts +++ b/plugins/automations/src/provider-permissions.ts @@ -1,5 +1,5 @@ import type { BbPluginApi } from "@bb/plugin-sdk"; -import type { PermissionMode } from "./rpc-types.js"; +import type { AgentEnvironment, PermissionMode } from "./rpc-types.js"; type ProviderPermissionApi = { sdk: { @@ -7,12 +7,29 @@ type ProviderPermissionApi = { }; }; +type ProviderRouting = NonNullable< + Parameters[0] +>; + +export function providerRoutingForEnvironment( + environment: AgentEnvironment, +): ProviderRouting { + if (environment.type === "reuse") { + return { environmentId: environment.environmentId }; + } + if (environment.type === "host" && environment.hostId !== undefined) { + return { hostId: environment.hostId }; + } + return {}; +} + export async function resolvePermissionMode( bb: ProviderPermissionApi, providerId: string, requested: PermissionMode | undefined, + routing: ProviderRouting = {}, ): Promise { - const providers = await bb.sdk.providers.list(); + const providers = await bb.sdk.providers.list(routing); const provider = providers.find((candidate) => candidate.id === providerId); if (provider === undefined || provider.available === false) { throw new Error(`Provider ${providerId} is not available.`); diff --git a/plugins/automations/src/rpc-types.ts b/plugins/automations/src/rpc-types.ts index 00459ff49b..f548cd7807 100644 --- a/plugins/automations/src/rpc-types.ts +++ b/plugins/automations/src/rpc-types.ts @@ -228,6 +228,15 @@ export type AutomationExecutionOptionsResponse = z.infer< typeof automationExecutionOptionsResponseSchema >; +export const automationPermissionOptionsResponseSchema = z + .object({ + permissionModes: z.array(permissionModeSchema), + }) + .strict(); +export type AutomationPermissionOptionsResponse = z.infer< + typeof automationPermissionOptionsResponseSchema +>; + export const automationResponseSchema = z .object({ id: z.string(), diff --git a/plugins/automations/src/rpc.ts b/plugins/automations/src/rpc.ts index 236ee7df6a..32ca304066 100644 --- a/plugins/automations/src/rpc.ts +++ b/plugins/automations/src/rpc.ts @@ -1,6 +1,7 @@ import { automationListResponseSchema, automationExecutionOptionsResponseSchema, + automationPermissionOptionsResponseSchema, automationResponseSchema, automationRunListResponseSchema, automationRunRpcResponseSchema, @@ -36,6 +37,10 @@ export const automationRpcContract = defineRpcContract({ input: projectAutomationInputSchema, output: automationExecutionOptionsResponseSchema, }, + automations_permission_options: { + input: projectAutomationInputSchema, + output: automationPermissionOptionsResponseSchema, + }, automations_create: { input: createAutomationInputSchema, output: automationResponseSchema, @@ -82,6 +87,11 @@ export function createRpcHandlers(service: AutomationService) { ) { return service.executionOptions(input); }, + automations_permission_options( + input: z.output, + ) { + return service.permissionOptions(input); + }, automations_create(input: z.output) { return service.create(input); }, diff --git a/plugins/automations/src/server-harness.test.ts b/plugins/automations/src/server-harness.test.ts index 47cc6ec07d..bde8227439 100644 --- a/plugins/automations/src/server-harness.test.ts +++ b/plugins/automations/src/server-harness.test.ts @@ -25,6 +25,7 @@ const rpcMethods = [ "automations_list", "automations_get", "automations_execution_options", + "automations_permission_options", "automations_create", "automations_update", "automations_delete", @@ -44,6 +45,7 @@ async function bootAutomationsPlugin( "auto", "full", ], + routedPermissionModes?: Array<"accept-edits" | "auto" | "full">, ): Promise { const host = createFakePluginHost({ pluginId: "automations", @@ -75,11 +77,16 @@ async function bootAutomationsPlugin( }, }, providers: { - async list() { + async list(routing) { + const permissionModes = + routing?.environmentId === "env_routed" && + routedPermissionModes !== undefined + ? routedPermissionModes + : supportedPermissionModes; return [ { id: "codex", - capabilities: { supportedPermissionModes }, + capabilities: { supportedPermissionModes: permissionModes }, }, ] as never; }, @@ -667,6 +674,14 @@ describe("automations server plugin harness", () => { models: [{ model: "gpt-5.6-codex", displayName: "5.6 Sol" }], permissionModes: ["accept-edits", "auto", "full"], }); + await expect( + harness.callRpc("automations_permission_options", { + projectId: PROJECT_ID, + automationId: created.id, + }), + ).resolves.toEqual({ + permissionModes: ["accept-edits", "auto", "full"], + }); await expect( harness.callRpc("automations_update", { @@ -733,6 +748,39 @@ describe("automations server plugin harness", () => { await harness.dispose(); }); + it("validates permission updates against the automation target environment", async () => { + const { harness } = await bootAutomationsPlugin( + ["accept-edits"], + ["full"], + ); + const created = await createAgentAutomation(harness); + + await expect( + harness.callRpc("automations_update", { + projectId: PROJECT_ID, + automationId: created.id, + agent: { + permissionMode: "full", + target: { + type: "environment", + environment: { + type: "reuse", + environmentId: "env_routed", + }, + }, + }, + }), + ).resolves.toMatchObject({ + execution: { + mode: "agent", + permissionMode: "full", + environment: { type: "reuse", environmentId: "env_routed" }, + }, + }); + + await harness.dispose(); + }); + it("dedupes manual runs through RPC idempotency keys", async () => { const { harness } = await bootAutomationsPlugin(); const automation = await createAgentAutomation(harness); diff --git a/plugins/automations/src/service.ts b/plugins/automations/src/service.ts index 50fd75e1fa..539c62676d 100644 --- a/plugins/automations/src/service.ts +++ b/plugins/automations/src/service.ts @@ -20,7 +20,10 @@ import { type Db, } from "./data.js"; import { createAutomationId } from "./ids.js"; -import { resolvePermissionMode } from "./provider-permissions.js"; +import { + providerRoutingForEnvironment, + resolvePermissionMode, +} from "./provider-permissions.js"; import { publishAutomationChange } from "./realtime.js"; import { AUTOMATION_RUNS_LIMIT_MAX, @@ -29,6 +32,7 @@ import { type AgentExecutionUpdate, type AutomationExecution, type AutomationExecutionOptionsResponse, + type AutomationPermissionOptionsResponse, type AutomationRunListResponse, type AutomationRunRpcResponse, type AutomationResponse, @@ -72,6 +76,10 @@ export interface AutomationService { projectId: string; automationId: string; }): Promise; + permissionOptions(input: { + projectId: string; + automationId: string; + }): Promise; create(input: ResolvedCreateAutomationInput): Promise; update(input: UpdateAutomationInput): Promise; delete(input: { @@ -405,13 +413,7 @@ export function createAutomationService(args: { "Execution options are only available for agent automations", ); } - const environment = execution.environment; - const routing = - environment.type === "reuse" - ? { environmentId: environment.environmentId } - : environment.type === "host" && environment.hostId !== undefined - ? { hostId: environment.hostId } - : {}; + const routing = providerRoutingForEnvironment(execution.environment); const loadModels = bb.sdk.providers.models; if (loadModels === undefined) { throw new Error("Provider model discovery is unavailable."); @@ -438,6 +440,33 @@ export function createAutomationService(args: { return { models, permissionModes }; }, + async permissionOptions(input) { + const automation = requireProjectAutomation(db, input); + const execution = parseAutomationExecution(automation.execution); + if (execution.mode !== "agent") { + throw new Error( + "Permission options are only available for agent automations", + ); + } + const environment = execution.environment; + const routing = + environment.type === "reuse" + ? { environmentId: environment.environmentId } + : environment.type === "host" && environment.hostId !== undefined + ? { hostId: environment.hostId } + : {}; + const providers = await bb.sdk.providers.list(routing); + const provider = providers.find( + (candidate) => candidate.id === execution.providerId, + ); + if (provider === undefined || provider.available === false) { + throw new Error(`Provider ${execution.providerId} is not available.`); + } + return { + permissionModes: provider.capabilities.supportedPermissionModes, + }; + }, + async create(payload) { await requireProjectAvailable(bb, payload.projectId); const now = Date.now(); @@ -448,6 +477,7 @@ export function createAutomationService(args: { bb, payload.execution.providerId, payload.execution.permissionMode, + providerRoutingForEnvironment(payload.execution.environment), ); } const automationId = createAutomationId(); @@ -511,6 +541,7 @@ export function createAutomationService(args: { bb, input.execution.providerId, input.execution.permissionMode, + providerRoutingForEnvironment(input.execution.environment), ); } const stored = await resolveStoredExecution({ @@ -522,6 +553,10 @@ export function createAutomationService(args: { stagedScriptFile = stored.writtenScriptFile; } if (input.agent !== undefined) { + const updatedExecution = applyAgentExecutionUpdate( + currentExecution, + input.agent, + ); if (input.agent.permissionMode !== undefined) { if (currentExecution.mode !== "agent") { throw new Error( @@ -530,14 +565,12 @@ export function createAutomationService(args: { } await resolvePermissionMode( bb, - currentExecution.providerId, + updatedExecution.providerId, input.agent.permissionMode, + providerRoutingForEnvironment(updatedExecution.environment), ); } - patch.execution = applyAgentExecutionUpdate( - currentExecution, - input.agent, - ); + patch.execution = updatedExecution; } let updated: AutomationRow | null; try {