Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/agent/toolBridgeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {
PipelineRunResponse,
} from "@/api/types.gen";
import type { ArgumentType, ComponentReference } from "@/models/componentSpec";
import type { AiSpec } from "@/routes/v2/pages/Editor/components/AiChat/serializeSpecForAi";
import type { AiSpec } from "@/routes/v2/shared/components/AiChat/serializeSpecForAi";

interface ValidationIssue {
type: string;
Expand Down
2 changes: 1 addition & 1 deletion src/routes/v2/pages/Editor/EditorV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Text } from "@/components/ui/typography";
import { ComponentLibraryProvider } from "@/providers/ComponentLibraryProvider";
import { ForcedSearchProvider } from "@/providers/ComponentLibraryProvider/ForcedSearchProvider";
import { DialogProvider } from "@/providers/DialogProvider/DialogProvider";
import { AiChatStoreProvider } from "@/routes/v2/shared/components/AiChat/AiChatStoreContext";
import { useDockAreaAccordion } from "@/routes/v2/shared/hooks/useDockAreaAccordion";
import { useFocusMode } from "@/routes/v2/shared/hooks/useFocusMode";
import { NodeRegistryProvider } from "@/routes/v2/shared/nodes/NodeRegistryContext";
Expand All @@ -28,7 +29,6 @@ import { WindowContainer } from "@/routes/v2/shared/windows/WindowContainer";
import { useWindowPersistence } from "@/routes/v2/shared/windows/windowPersistence";
import type { PipelineRef } from "@/services/pipelineStorage/types";

import { AiChatStoreProvider } from "./components/AiChat/AiChatStoreContext";
import { useDebugPanelWindow } from "./components/DebugPanel";
import { DriverPermissionGate } from "./components/DriverPermissionGate";
import { EditorMenuBar } from "./components/EditorMenuBar/EditorMenuBar";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ vi.mock("@/utils/submitPipeline", () => ({
),
}));

import { createToolBridge } from "./toolBridge";
import { createEditorToolBridge } from "./toolBridge";

/**
* Pass-through undo stub: records every withGroup label invoked so tests
Expand Down Expand Up @@ -91,7 +91,7 @@ function buildSpec(): ComponentSpec {
function makeBridge() {
const spec = buildSpec();
const undo = new RecordingUndo();
const bridge = createToolBridge({
const bridge = createEditorToolBridge({
getSpec: () => spec,
getActiveSubgraphPath: () => [],
undo,
Expand All @@ -101,7 +101,7 @@ function makeBridge() {

function makeEmptyBridge() {
const undo = new RecordingUndo();
const bridge = createToolBridge({
const bridge = createEditorToolBridge({
getSpec: () => null,
getActiveSubgraphPath: () => [],
undo,
Expand All @@ -119,7 +119,7 @@ function makeBackendBridge(
) {
const spec = buildSpec();
const undo = new RecordingUndo();
const bridge = createToolBridge({
const bridge = createEditorToolBridge({
getSpec: () => spec,
getActiveSubgraphPath: () => [],
undo,
Expand All @@ -130,7 +130,7 @@ function makeBackendBridge(
return { bridge, spec };
}

describe("createToolBridge", () => {
describe("createEditorToolBridge", () => {
describe("requireSpec guard", () => {
it("throws on every mutating call when getSpec returns null", async () => {
const { bridge } = makeEmptyBridge();
Expand All @@ -146,7 +146,7 @@ describe("createToolBridge", () => {
it("returns the serialized spec with the active subgraph path", async () => {
const spec = buildSpec();
const undo = new RecordingUndo();
const bridge = createToolBridge({
const bridge = createEditorToolBridge({
getSpec: () => spec,
getActiveSubgraphPath: () => ["preprocess"],
undo,
Expand Down Expand Up @@ -314,7 +314,7 @@ describe("createToolBridge", () => {
}),
);
const undo = new RecordingUndo();
const bridge = createToolBridge({
const bridge = createEditorToolBridge({
getSpec: () => spec,
getActiveSubgraphPath: () => [],
undo,
Expand Down Expand Up @@ -372,7 +372,7 @@ describe("createToolBridge", () => {
}),
);
const undo = new RecordingUndo();
const bridge = createToolBridge({
const bridge = createEditorToolBridge({
getSpec: () => spec,
getActiveSubgraphPath: () => [],
undo,
Expand All @@ -386,7 +386,7 @@ describe("createToolBridge", () => {
it("maps validation issues into the wire shape", async () => {
const spec = new ComponentSpec({ $id: "spec_1", name: "" });
const undo = new RecordingUndo();
const bridge = createToolBridge({
const bridge = createEditorToolBridge({
getSpec: () => spec,
getActiveSubgraphPath: () => [],
undo,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import type {
ValidationResult,
} from "@/agent/toolBridgeApi";
import { validateSpec } from "@/models/componentSpec/validation/validateSpec";
import { serializeSpecForAi } from "@/routes/v2/pages/Editor/components/AiChat/serializeSpecForAi";
import {
connectNodes,
deleteSelectedEdgesByEdgeIds,
Expand Down Expand Up @@ -40,10 +39,22 @@ import {
renameTask,
unpackSubgraphTask,
} from "@/routes/v2/pages/Editor/store/actions/task.actions";
import { serializeSpecForAi } from "@/routes/v2/shared/components/AiChat/serializeSpecForAi";
import type { BridgeDeps } from "@/routes/v2/shared/components/AiChat/toolBridge/utils";
import {
computeNextPosition,
requireSpec,
} from "@/routes/v2/shared/components/AiChat/toolBridge/utils";
import type { UndoGroupable } from "@/routes/v2/shared/nodes/types";
import { hydrateComponentReference } from "@/services/componentService";

import type { BridgeDeps } from "./utils";
import { computeNextPosition, requireSpec } from "./utils";
/**
* CSOM handlers need the Editor's undo store to make the agent's spec
* edits user-visible and undoable as a single step. `undo` lives here
* (not in the shared `BridgeDeps`) because only the Editor's mutating
* bridge depends on it.
*/
export type CsomBridgeDeps = BridgeDeps & { undo: UndoGroupable };

type CsomHandlers = Pick<
ToolBridgeApi,
Expand All @@ -67,7 +78,7 @@ type CsomHandlers = Pick<
| "validatePipeline"
>;

export function createCsomBridgeHandlers(deps: BridgeDeps): CsomHandlers {
export function createCsomBridgeHandlers(deps: CsomBridgeDeps): CsomHandlers {
return {
async getPipelineState() {
return serializeSpecForAi(requireSpec(deps), {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,21 @@
* changes are picked up without rebuilding the bridge.
*/
import type { ToolBridgeApi } from "@/agent/toolBridgeApi";
import { createDebugBridgeHandlers } from "@/routes/v2/shared/components/AiChat/toolBridge/debugBridge";
import { createRunBridgeHandlers } from "@/routes/v2/shared/components/AiChat/toolBridge/runBridge";

import type { CsomBridgeDeps } from "./csomBridge";
import { createCsomBridgeHandlers } from "./csomBridge";
import { createDebugBridgeHandlers } from "./debugBridge";
import { createRunBridgeHandlers } from "./runBridge";
import type { BridgeDeps } from "./utils";

export type { BridgeDeps } from "./utils";
export type EditorToolBridgeDeps = CsomBridgeDeps;

export function createToolBridge(deps: BridgeDeps): ToolBridgeApi {
/**
* Full Editor bridge — shared run/debug handlers plus the Editor's
* spec-mutating CSOM handlers (which require the undo store).
*/
export function createEditorToolBridge(
deps: EditorToolBridgeDeps,
): ToolBridgeApi {
return {
...createCsomBridgeHandlers(deps),
...createRunBridgeHandlers(deps),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { BlockStack } from "@/components/ui/layout";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Text } from "@/components/ui/typography";
import { serializeComponentSpec } from "@/models/componentSpec";
import { CodeBlock } from "@/routes/v2/pages/Editor/components/PinnedTaskContent/components/CodeBlock";
import { CodeBlock } from "@/routes/v2/shared/components/CodeBlock";
import { useSpec } from "@/routes/v2/shared/providers/SpecContext";
import { tracking } from "@/utils/tracking";
import { componentSpecToText } from "@/utils/yaml";
Expand Down
30 changes: 20 additions & 10 deletions src/routes/v2/pages/Editor/hooks/useAiChatWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useEffect } from "react";

import { AiChatContent } from "@/routes/v2/pages/Editor/components/AiChat/AiChatContent";
import { createEditorToolBridge } from "@/routes/v2/pages/Editor/components/AiChat/toolBridge";
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
import { AiChatContent } from "@/routes/v2/shared/components/AiChat/AiChatContent";
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";

const AI_CHAT_WINDOW_ID = "ai-assistant-chat";

export function useAiChatWindow(enabled: boolean) {
const { windows } = useSharedStores();
const editorSession = useEditorSession();

useEffect(() => {
if (!enabled) {
Expand All @@ -15,13 +18,20 @@ export function useAiChatWindow(enabled: boolean) {
}
if (windows.getWindowById(AI_CHAT_WINDOW_ID)) return;

windows.openWindow(<AiChatContent />, {
id: AI_CHAT_WINDOW_ID,
title: "AI Assistant",
position: { x: 100, y: 80 },
size: { width: 380, height: 520 },
disabledActions: ["close"],
persisted: true,
});
}, [enabled, windows]);
windows.openWindow(
<AiChatContent
createBridge={(deps) =>
createEditorToolBridge({ ...deps, undo: editorSession.undo })
}
/>,
{
id: AI_CHAT_WINDOW_ID,
title: "AI Assistant",
position: { x: 100, y: 80 },
size: { width: 380, height: 520 },
disabledActions: ["close"],
persisted: true,
},
);
}, [enabled, windows, editorSession]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react";

import type { RecentPipelineRun } from "@/agent/session";
import type { ToolBridgeApi } from "@/agent/toolBridgeApi";
import { useAuthLocalStorage } from "@/components/shared/Authentication/useAuthLocalStorage";
import { BlockStack } from "@/components/ui/layout";
import { useAiProviderSettings } from "@/hooks/useAiProviderSettings";
import useToastNotification from "@/hooks/useToastNotification";
import { useBackend } from "@/providers/BackendProvider";
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";
import { fetchPipelineRuns } from "@/services/pipelineRunService";
import type { PipelineRun } from "@/types/pipelineRun";
Expand All @@ -17,10 +17,22 @@ import { useAiChatStore } from "./AiChatStoreContext";
import { AiProviderSetup } from "./components/AiProviderSetup";
import { ChatInput } from "./components/ChatInput";
import { ChatMessageList } from "./components/ChatMessageList";
import { createToolBridge } from "./toolBridge";
import type { BridgeDeps } from "./toolBridge/utils";

const RECENT_RUNS_LIMIT = 5;

/**
* Deps every consumer can supply from the shared navigation/backend/auth
* stores. The page-specific bridge factory turns these into a concrete
* `ToolBridgeApi` (Editor adds spec-mutating CSOM handlers; RunView stays
* read-only).
*/
type CreateBridge = (deps: BridgeDeps) => ToolBridgeApi;

interface AiChatContentProps {
createBridge: CreateBridge;
}

function projectRecentRuns(runs: PipelineRun[]): RecentPipelineRun[] {
return runs.slice(0, RECENT_RUNS_LIMIT).map((run) => ({
id: run.id,
Expand All @@ -31,11 +43,12 @@ function projectRecentRuns(runs: PipelineRun[]): RecentPipelineRun[] {
}));
}

export const AiChatContent = observer(function AiChatContent() {
export const AiChatContent = observer(function AiChatContent({
createBridge,
}: AiChatContentProps) {
const aiChat = useAiChatStore();
const notify = useToastNotification();
const { navigation } = useSharedStores();
const editorSession = useEditorSession();
const { backendUrl } = useBackend();
const authStorage = useAuthLocalStorage();
const queryClient = useQueryClient();
Expand All @@ -56,18 +69,17 @@ export const AiChatContent = observer(function AiChatContent() {
authTokenRef.current = authToken;
}, [authToken]);

// The bridge closes over the navigation + undo stores plus the
// The bridge closes over the navigation store plus the
// backend/auth/queryClient deps captured via refs. A single instance
// per AiChatContent mount is correct: each method call re-reads
// `navigation.rootSpec`, the active path, and the latest backend
// values lazily so navigation / backend changes are picked up
// without rebuilding the bridge.
const [bridge] = useState(() =>
createToolBridge({
createBridge({
getSpec: () => navigation.rootSpec,
getActiveSubgraphPath: () =>
navigation.navigationPath.slice(1).map((e) => e.displayName),
undo: editorSession.undo,
getBackendUrl: () => backendUrlRef.current,
getAuthToken: () => authTokenRef.current,
queryClient,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Text } from "@/components/ui/typography";
import type { ChatMessage as ChatMessageType } from "@/routes/v2/pages/Editor/components/AiChat/types";
import type { ChatMessage as ChatMessageType } from "@/routes/v2/shared/components/AiChat/types";

import { MessageBubble } from "./MessageBubble";
import { renderMarkdown } from "./renderMarkdown";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";

import { BlockStack } from "@/components/ui/layout";
import { Text } from "@/components/ui/typography";
import type { ChatMessage as ChatMessageType } from "@/routes/v2/pages/Editor/components/AiChat/types";
import type { ChatMessage as ChatMessageType } from "@/routes/v2/shared/components/AiChat/types";

import { ChatMessage } from "./ChatMessage";
import { ThinkingMessage } from "./ThinkingMessage";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { DragEvent } from "react";

import ComponentDetailsDialog from "@/components/shared/Dialogs/ComponentDetailsDialog";
import type { ComponentRefData } from "@/routes/v2/pages/Editor/components/AiChat/types";
import type { ComponentRefData } from "@/routes/v2/shared/components/AiChat/types";
import type { ComponentReference } from "@/utils/componentSpec";

import { ChatEntityChip } from "./ChatEntityChip";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { Link } from "@/components/ui/link";
import { Separator } from "@/components/ui/separator";
import { Paragraph, Text } from "@/components/ui/typography";
import { getComponentQueryKey } from "@/hooks/useHydrateComponentReference";
import type { ComponentRefData } from "@/routes/v2/pages/Editor/components/AiChat/types";
import { CodeBlock } from "@/routes/v2/pages/Editor/components/PinnedTaskContent/components/CodeBlock";
import type { ComponentRefData } from "@/routes/v2/shared/components/AiChat/types";
import { CodeBlock } from "@/routes/v2/shared/components/CodeBlock";
import { hydrateComponentReference } from "@/services/componentService";

import { ComponentChip } from "./ComponentChip";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import type { QueryClient } from "@tanstack/react-query";

import type { ComponentSpec } from "@/models/componentSpec";
import type { UndoGroupable } from "@/routes/v2/shared/nodes/types";
import { EDITOR_POSITION_ANNOTATION } from "@/utils/annotations";

const DEFAULT_POSITION = { x: 250, y: 250 };
Expand All @@ -23,7 +22,6 @@ const POSITION_OFFSET = 200;
export interface BridgeDeps {
getSpec: () => ComponentSpec | null;
getActiveSubgraphPath: () => string[];
undo: UndoGroupable;
getBackendUrl?: () => string;
getAuthToken?: () => string | undefined;
queryClient?: QueryClient;
Expand Down
Loading