From 373cce216267ed3217b7b3ea839352a21321f110 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Wed, 4 Nov 2026 11:45:58 -0700 Subject: [PATCH 1/4] fix(tangent): float view-only pipeline controls instead of a second nav bar A pipeline you can't edit, opened in a Tangent tab, rendered the standalone page's whole menu bar inside the tab: the home logo, the pipeline name and every app-wide button, stacked under the app's own nav. When embedded, drop the bar and float what the app's nav lacks (the View only badge, the View and Windows menus, and the AI model picker) in the canvas's top-right corner. The standalone page keeps its bar. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../EmbeddedPipelineViewControls.tsx | 31 ++++++++++ .../RemotePipelineView.integration.test.tsx | 5 +- .../RemotePipelineView.test.tsx | 24 ++++++++ .../RemotePipelineView/RemotePipelineView.tsx | 60 ++++++++++++++----- 4 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx new file mode 100644 index 000000000..bf80e1908 --- /dev/null +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx @@ -0,0 +1,31 @@ +import { Panel } from "@xyflow/react"; +import type { PropsWithChildren } from "react"; + +import { AiModelQuickSelect } from "@/components/layout/AiModelQuickSelect"; +import { Badge } from "@/components/ui/badge"; +import { InlineStack } from "@/components/ui/layout"; + +export function EmbeddedPipelineViewControls({ children }: PropsWithChildren) { + return ( + + + + View only + + + {children} + + + + + ); +} diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.integration.test.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.integration.test.tsx index bb72954af..be9e1142d 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.integration.test.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.integration.test.tsx @@ -389,8 +389,11 @@ it.each([false, true])("inspects pipelines (embed=%s)", async (embedded) => { screen.getByRole("menuitemcheckbox", { name: "Sidekick" }), ).toBeInTheDocument(); await user.keyboard("{Escape}"); + const viewControls = embedded + ? screen.getByRole("group", { name: "Pipeline view controls" }) + : screen.getByRole("banner"); expect( - within(screen.getByRole("banner")).queryByRole("button", { + within(viewControls).queryByRole("button", { name: /^(Details|Clone pipeline|Submit Run)$/, }), ).not.toBeInTheDocument(); diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx index cedc694e5..61585758b 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx @@ -14,6 +14,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactFlowProps } from "@xyflow/react"; @@ -110,6 +111,9 @@ vi.mock("@xyflow/react", async (original) => { vi.mock("@/routes/v2/shared/components/AppMenuActions", () => ({ AppMenuActions: () => null, })); +vi.mock("@/components/layout/AiModelQuickSelect", () => ({ + AiModelQuickSelect: () => , +})); vi.mock("@/providers/ComponentLibraryProvider", () => ({ ComponentLibraryProvider: ({ children }: PropsWithChildren) => children, })); @@ -226,6 +230,26 @@ it("starts embedded views compact with Sidekick disabled and keeps the minimap a expect(await screen.findByRole("button", { name: "Fit view" })).toBeVisible(); }); +it("floats the view controls on an embedded canvas instead of a menu bar", async () => { + const spec = new YamlDeserializer(new IncrementingIdGenerator()).deserialize({ + name: "Shared pipeline", + implementation: { graph: { tasks: {} } }, + }); + render(, { + wrapper: RouterWrapper, + }); + const controls = await screen.findByRole("group", { + name: "Pipeline view controls", + }); + expect(within(controls).getByText("View only")).toBeInTheDocument(); + for (const name of ["View", "Windows", "AI model"]) { + expect(within(controls).getByRole("button", { name })).toBeInTheDocument(); + } + expect(screen.queryByRole("banner")).toBeNull(); + expect(screen.queryByRole("link", { name: "Home" })).toBeNull(); + expect(screen.queryByText("Shared pipeline")).toBeNull(); +}); + it("copies the selected task only while the view is active", async () => { const writeText = vi.fn().mockResolvedValue(undefined); const originalClipboard = Object.getOwnPropertyDescriptor( diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.tsx index 224f5acdc..b18be5f05 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.tsx @@ -6,7 +6,7 @@ import { useEffect } from "react"; import { ComponentEditorProvider } from "@/components/shared/ComponentEditor/ComponentEditorProvider"; import { useFlagValue } from "@/components/shared/Settings/useFlags"; import { Button } from "@/components/ui/button"; -import { InlineStack } from "@/components/ui/layout"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; import type { ComponentSpec } from "@/models/componentSpec"; import { ComponentLibraryProvider } from "@/providers/ComponentLibraryProvider"; @@ -45,6 +45,7 @@ import { useWindowPersistence } from "@/routes/v2/shared/windows/windowPersisten import type { PipelineFile } from "@/services/pipelineStorage/PipelineFile"; import { getEditorPipelineIdentity } from "@/services/pipelineStorage/types"; +import { EmbeddedPipelineViewControls } from "./EmbeddedPipelineViewControls"; import { RemotePipelineActions } from "./RemotePipelineActions"; import { RemotePipelineShell } from "./RemotePipelineShell"; import { remotePipelineViewRegistry } from "./remotePipelineViewRegistry"; @@ -136,20 +137,18 @@ const RemotePipelineContent = observer(function RemotePipelineContent({ const activeSpec = navigation.activeSpec; - return ( - - - - - } - > + const menus = ( + <> + + + + ); + + const content = ( + <> {file?.editingError && (
@@ -189,13 +188,42 @@ const RemotePipelineContent = observer(function RemotePipelineContent({ spec={activeSpec} nodesDraggable={false} className="h-full" - /> + > + {embedded && ( + + {menus} + + )} +
)} + + ); + + if (embedded) { + return ( + + {content} + + ); + } + + return ( + + {content} ); }); From 4969c96ef1b6ff450a53b2f762134f48135b8a02 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Wed, 4 Nov 2026 14:11:51 -0700 Subject: [PATCH 2/4] fix(tangent): leave the AI model picker out of the view-only controls Tangent's chat has its own model picker, so a second one floating on the canvas would be confusing. The floating controls keep only the View only badge and the View and Windows menus. Co-Authored-By: Claude Opus 5.5 --- .../RemotePipelineView/EmbeddedPipelineViewControls.tsx | 2 -- .../RemotePipelineView/RemotePipelineView.test.tsx | 6 ++---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx index bf80e1908..ceaa9c34f 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx @@ -1,7 +1,6 @@ import { Panel } from "@xyflow/react"; import type { PropsWithChildren } from "react"; -import { AiModelQuickSelect } from "@/components/layout/AiModelQuickSelect"; import { Badge } from "@/components/ui/badge"; import { InlineStack } from "@/components/ui/layout"; @@ -24,7 +23,6 @@ export function EmbeddedPipelineViewControls({ children }: PropsWithChildren) { {children} - ); diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx index 61585758b..be6a0c769 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx @@ -111,9 +111,6 @@ vi.mock("@xyflow/react", async (original) => { vi.mock("@/routes/v2/shared/components/AppMenuActions", () => ({ AppMenuActions: () => null, })); -vi.mock("@/components/layout/AiModelQuickSelect", () => ({ - AiModelQuickSelect: () => , -})); vi.mock("@/providers/ComponentLibraryProvider", () => ({ ComponentLibraryProvider: ({ children }: PropsWithChildren) => children, })); @@ -242,7 +239,8 @@ it("floats the view controls on an embedded canvas instead of a menu bar", async name: "Pipeline view controls", }); expect(within(controls).getByText("View only")).toBeInTheDocument(); - for (const name of ["View", "Windows", "AI model"]) { + expect(within(controls).getAllByRole("button")).toHaveLength(2); + for (const name of ["View", "Windows"]) { expect(within(controls).getByRole("button", { name })).toBeInTheDocument(); } expect(screen.queryByRole("banner")).toBeNull(); From e50213427dbcf7b36b320ff072efebfa4eefeb03 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Wed, 4 Nov 2026 14:15:29 -0700 Subject: [PATCH 3/4] fix(tangent): label pipelines you can't edit "Read only" Rename the badge from "View only" on both the floating controls and the standalone pipeline page, sharing one ReadOnlyBadge so the two can't drift. Co-Authored-By: Claude Opus 5.5 --- src/routes/remotePipelineEditor.test.tsx | 4 ++-- .../RemotePipelineView/EmbeddedPipelineViewControls.tsx | 7 +++---- .../components/RemotePipelineView/ReadOnlyBadge.tsx | 9 +++++++++ .../RemotePipelineView/RemotePipelineShell.tsx | 9 +++------ .../RemotePipelineView/RemotePipelineView.test.tsx | 8 ++++---- 5 files changed, 21 insertions(+), 16 deletions(-) create mode 100644 src/routes/v2/pages/Editor/components/RemotePipelineView/ReadOnlyBadge.tsx diff --git a/src/routes/remotePipelineEditor.test.tsx b/src/routes/remotePipelineEditor.test.tsx index 4fb8110c7..8576f80a6 100644 --- a/src/routes/remotePipelineEditor.test.tsx +++ b/src/routes/remotePipelineEditor.test.tsx @@ -548,7 +548,7 @@ describe("remote pipeline editor entry", () => { }); renderEditor(); expect(screen.getByRole("alert")).toHaveTextContent(message); - expect(screen.queryByText("View only")).not.toBeInTheDocument(); + expect(screen.queryByText("Read only")).not.toBeInTheDocument(); expect(Boolean(screen.queryByRole("button", { name: "Retry" }))).toBe( retryable, ); @@ -654,7 +654,7 @@ describe("remote pipeline editor entry", () => { }); renderEditor(); expect(screen.getByRole("alert")).toHaveTextContent(message); - expect(screen.queryByText("View only")).not.toBeInTheDocument(); + expect(screen.queryByText("Read only")).not.toBeInTheDocument(); expect(screen.getByRole("link", { name: "Home" })).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Sign in" }), diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx index ceaa9c34f..f296939d7 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx @@ -1,9 +1,10 @@ import { Panel } from "@xyflow/react"; import type { PropsWithChildren } from "react"; -import { Badge } from "@/components/ui/badge"; import { InlineStack } from "@/components/ui/layout"; +import { ReadOnlyBadge } from "./ReadOnlyBadge"; + export function EmbeddedPipelineViewControls({ children }: PropsWithChildren) { return ( - - View only - + {children} diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/ReadOnlyBadge.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/ReadOnlyBadge.tsx new file mode 100644 index 000000000..754af211a --- /dev/null +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/ReadOnlyBadge.tsx @@ -0,0 +1,9 @@ +import { Badge } from "@/components/ui/badge"; + +export function ReadOnlyBadge() { + return ( + + Read only + + ); +} diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineShell.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineShell.tsx index c24b4a845..9a62caedb 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineShell.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineShell.tsx @@ -1,6 +1,5 @@ import type { PropsWithChildren, ReactNode } from "react"; -import { Badge } from "@/components/ui/badge"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; import { AppMenuActions } from "@/routes/v2/shared/components/AppMenuActions"; @@ -9,6 +8,8 @@ import { CanvasMenuBar, } from "@/routes/v2/shared/components/CanvasMenuBar"; +import { ReadOnlyBadge } from "./ReadOnlyBadge"; + export function RemotePipelineShell({ name = "Pipeline", readOnly = false, @@ -48,11 +49,7 @@ export function RemotePipelineShell({ > {name} - {readOnly && ( - - View only - - )} + {readOnly && } {menus && ( diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx index be6a0c769..785a9aede 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx @@ -180,7 +180,7 @@ it("shows a read-only graph and navigates subgraphs without changing the spec", { wrapper: RouterWrapper }, ); await screen.findByText("First task"); - expect(screen.getByText("View only")).toBeInTheDocument(); + expect(screen.getByText("Read only")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Fit view" })).toBeInTheDocument(); expect(canvas).toMatchObject({ nodesConnectable: false, @@ -213,7 +213,7 @@ it("starts embedded views compact with Sidekick disabled and keeps the minimap a const { container } = render(, { wrapper: RouterWrapper, }); - await screen.findByText("View only"); + await screen.findByText("Read only"); expect(container.querySelector('[data-dock-area="left"]')).toBeNull(); expect(container.querySelector('[data-dock-area="right"]')).toHaveStyle({ width: "36px", @@ -238,7 +238,7 @@ it("floats the view controls on an embedded canvas instead of a menu bar", async const controls = await screen.findByRole("group", { name: "Pipeline view controls", }); - expect(within(controls).getByText("View only")).toBeInTheDocument(); + expect(within(controls).getByText("Read only")).toBeInTheDocument(); expect(within(controls).getAllByRole("button")).toHaveLength(2); for (const name of ["View", "Windows"]) { expect(within(controls).getByRole("button", { name })).toBeInTheDocument(); @@ -428,7 +428,7 @@ it.each(["task", "input", "output"])( await waitFor(() => expect(editor.selectedNodeId).toBe(node.$id)); expect(editor.selectedNodeType).toBe(nodeType); expect(canvas.nodes?.some((item) => item.id === node.$id)).toBe(true); - expect(screen.getByText("View only")).toBeInTheDocument(); + expect(screen.getByText("Read only")).toBeInTheDocument(); await waitFor(() => expect(router.state.location.search.nodeId).toBe("shared"), ); From de069e2d2c9c270c4b121d0f7f3ec16e889ef829 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Thu, 5 Nov 2026 10:44:53 -0700 Subject: [PATCH 4/4] refactor(tangent): share the read-only pipeline frame and menu surface The embedded read-only view copied the shell's root element, and the floating controls box repeated the menu bar's background. Extract RemotePipelineFrame for the former and export CANVAS_MENU_SURFACE_CLASS for the latter, so both surfaces stay in step. Co-Authored-By: Claude Opus 5.5 --- .../EmbeddedPipelineViewControls.tsx | 7 +++++- .../RemotePipelineShell.tsx | 22 +++++++++++++------ .../RemotePipelineView/RemotePipelineView.tsx | 18 +++++---------- .../v2/shared/components/CanvasMenuBar.tsx | 8 ++++++- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx index f296939d7..c41aaf62d 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx @@ -2,6 +2,8 @@ import { Panel } from "@xyflow/react"; import type { PropsWithChildren } from "react"; import { InlineStack } from "@/components/ui/layout"; +import { cn } from "@/lib/utils"; +import { CANVAS_MENU_SURFACE_CLASS } from "@/routes/v2/shared/components/CanvasMenuBar"; import { ReadOnlyBadge } from "./ReadOnlyBadge"; @@ -16,7 +18,10 @@ export function EmbeddedPipelineViewControls({ children }: PropsWithChildren) { gap="2" blockAlign="center" wrap="nowrap" - className="rounded-md border border-white/10 bg-stone-900 px-2 py-1 shadow-md" + className={cn( + "rounded-md border border-white/10 px-2 py-1 shadow-md", + CANVAS_MENU_SURFACE_CLASS, + )} > diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineShell.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineShell.tsx index 9a62caedb..96329c603 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineShell.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineShell.tsx @@ -10,6 +10,19 @@ import { import { ReadOnlyBadge } from "./ReadOnlyBadge"; +export function RemotePipelineFrame({ children }: PropsWithChildren) { + return ( + + {children} + + ); +} + export function RemotePipelineShell({ name = "Pipeline", readOnly = false, @@ -21,12 +34,7 @@ export function RemotePipelineShell({ menus?: ReactNode; }>) { return ( - + {children} - + ); } diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.tsx index b18be5f05..4b64fd8ba 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.tsx @@ -6,7 +6,7 @@ import { useEffect } from "react"; import { ComponentEditorProvider } from "@/components/shared/ComponentEditor/ComponentEditorProvider"; import { useFlagValue } from "@/components/shared/Settings/useFlags"; import { Button } from "@/components/ui/button"; -import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; import type { ComponentSpec } from "@/models/componentSpec"; import { ComponentLibraryProvider } from "@/providers/ComponentLibraryProvider"; @@ -47,7 +47,10 @@ import { getEditorPipelineIdentity } from "@/services/pipelineStorage/types"; import { EmbeddedPipelineViewControls } from "./EmbeddedPipelineViewControls"; import { RemotePipelineActions } from "./RemotePipelineActions"; -import { RemotePipelineShell } from "./RemotePipelineShell"; +import { + RemotePipelineFrame, + RemotePipelineShell, +} from "./RemotePipelineShell"; import { remotePipelineViewRegistry } from "./remotePipelineViewRegistry"; const VIEW_PRESETS: ViewPreset[] = [ @@ -205,16 +208,7 @@ const RemotePipelineContent = observer(function RemotePipelineContent({ ); if (embedded) { - return ( - - {content} - - ); + return {content}; } return ( diff --git a/src/routes/v2/shared/components/CanvasMenuBar.tsx b/src/routes/v2/shared/components/CanvasMenuBar.tsx index 3ae3502d4..85d97f620 100644 --- a/src/routes/v2/shared/components/CanvasMenuBar.tsx +++ b/src/routes/v2/shared/components/CanvasMenuBar.tsx @@ -3,14 +3,20 @@ import type { PropsWithChildren } from "react"; import logo from "/Tangle_Icon_White.png"; import { InlineStack } from "@/components/ui/layout"; +import { cn } from "@/lib/utils"; import { APP_ROUTES } from "@/routes/appRoutes"; import { TOP_NAV_HEIGHT } from "@/utils/constants"; import { tracking } from "@/utils/tracking"; +export const CANVAS_MENU_SURFACE_CLASS = "bg-stone-900"; + export function CanvasMenuBar({ children }: PropsWithChildren) { return (