Skip to content
Open
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: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,10 @@
"react-dom": "^19.2.6",
"react-error-boundary": "^6.1.2",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"react-scan": "^0.5.6",
"react-toastify": "^11.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tailwindcss-animate": "^1.0.7",
Expand Down
879 changes: 874 additions & 5 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,12 @@ export const ExistingFlags: ConfigFlags = {
default: true,
category: "beta",
},

["ai-assistant"]: {
name: "AI Assistant",
description:
"Enable the AI Assistant panel in the V2 editor. Lets you chat with an assistant about your pipeline.",
default: false,
category: "beta",
},
};
14 changes: 11 additions & 3 deletions src/routes/v2/pages/Editor/EditorV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ReactFlowProvider } from "@xyflow/react";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";

import { useFlagValue } from "@/components/shared/Settings/useFlags";
import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Spinner } from "@/components/ui/spinner";
Expand All @@ -27,11 +28,13 @@ 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";
import { EmptyEditorState } from "./components/EmptyEditorState";
import { FlowCanvas } from "./components/FlowCanvas/FlowCanvas";
import { useGatedAiChatWindow } from "./hooks/useAiChatWindow";
import { useComponentLibraryWindow } from "./hooks/useComponentLibraryWindow";
import { useEditorEscapeShortcut } from "./hooks/useEditorEscapeShortcut";
import { useHistoryWindow } from "./hooks/useHistoryWindow";
Expand Down Expand Up @@ -90,6 +93,9 @@ const PipelineEditor = withSuspenseWrapper(
useDebugPanelWindow();
useSeedInitialDockLayoutFromPreset();

const aiEnabled = useFlagValue("ai-assistant");
useGatedAiChatWindow(aiEnabled);

const activeSpec = navigation.activeSpec;

if (!activeSpec) return null;
Expand Down Expand Up @@ -170,9 +176,11 @@ export function EditorV2() {
<div className="h-full w-full flex flex-col bg-slate-100 select-none">
<SharedStoreProvider>
<EditorSessionProvider>
<DialogProvider>
<EditorV2Content pipelineRef={pipelineRef} />
</DialogProvider>
<AiChatStoreProvider>
<DialogProvider>
<EditorV2Content pipelineRef={pipelineRef} />
</DialogProvider>
</AiChatStoreProvider>
</EditorSessionProvider>
</SharedStoreProvider>
</div>
Expand Down
29 changes: 29 additions & 0 deletions src/routes/v2/pages/Editor/components/AiChat/AiChatContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { observer } from "mobx-react-lite";

import { BlockStack } from "@/components/ui/layout";
import useToastNotification from "@/hooks/useToastNotification";

import { useAiChatStore } from "./AiChatStoreContext";
import { ChatInput } from "./components/ChatInput";
import { ChatMessageList } from "./components/ChatMessageList";

export const AiChatContent = observer(function AiChatContent() {
const aiChat = useAiChatStore();
const notify = useToastNotification();

function handleSend(prompt: string) {
aiChat.sendMessage(prompt, {
onError: (msg) => notify(msg, "error"),
});
}

return (
<BlockStack className="h-full" gap="0">
<ChatMessageList
messages={aiChat.messages}
thinkingText={aiChat.thinkingText}
/>
<ChatInput isPending={aiChat.isPending} onSubmit={handleSend} />
</BlockStack>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
import { useState } from "react";

import {
createRequiredContext,
useRequiredContext,
} from "@/hooks/useRequiredContext";

import { AiChatStore } from "./aiChatStore";

const AiChatStoreCtx = createRequiredContext<AiChatStore>("AiChatStoreContext");

export function AiChatStoreProvider({ children }: { children: ReactNode }) {
const [store] = useState(() => new AiChatStore());

return (
<AiChatStoreCtx.Provider value={store}>{children}</AiChatStoreCtx.Provider>
);
}

export function useAiChatStore(): AiChatStore {
return useRequiredContext(AiChatStoreCtx);
}
11 changes: 11 additions & 0 deletions src/routes/v2/pages/Editor/components/AiChat/aiChat.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface ComponentRefData {
name: string;
yamlText: string;
}

export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
componentReferences?: Record<string, ComponentRefData>;
}
82 changes: 82 additions & 0 deletions src/routes/v2/pages/Editor/components/AiChat/aiChatStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { action, makeObservable, observable, runInAction } from "mobx";

import { getErrorMessage } from "@/utils/string";

import type { ChatMessage } from "./aiChat.types";

function generateMessageId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}

interface SendMessageOptions {
onError: (message: string) => void;
}

/**
* Stores AI chat state (messages, thread, pending status) outside the
* React component tree so it survives window minimize / hide / unmount.
*
* PR 1: `sendMessage` is a stub that appends a hardcoded assistant echo.
* The real worker / LLM round-trip lands in PR 2 / PR 3.
*/
export class AiChatStore {
@observable.shallow accessor messages: ChatMessage[] = [];
@observable accessor threadId: string | undefined = undefined;
@observable accessor thinkingText: string | null = null;
@observable accessor isPending = false;

private abortController: AbortController | null = null;

constructor() {
makeObservable(this);
}

@action resetState() {
this.messages = [];
this.threadId = undefined;
this.thinkingText = null;
this.isPending = false;
this.abortController?.abort();
this.abortController = null;
}

abort() {
this.abortController?.abort();
}

async sendMessage(prompt: string, options: SendMessageOptions) {
runInAction(() => {
this.messages = [
...this.messages,
{ id: generateMessageId(), role: "user", content: prompt },
];
this.isPending = true;
this.thinkingText = null;
});

try {
/**
* Echo the user's message back to the user.
* TODO: replace with actual AI response.
*/
runInAction(() => {
this.messages = [
...this.messages,
{
id: generateMessageId(),
role: "assistant",
content: `${prompt}`,
},
];
});
} catch (error) {
options.onError(`AI request failed: ${getErrorMessage(error)}`);
} finally {
this.abortController = null;
runInAction(() => {
this.isPending = false;
this.thinkingText = null;
});
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type ButtonHTMLAttributes, forwardRef } from "react";

import { Icon, type IconName } from "@/components/ui/icon";
import { cn } from "@/lib/utils";

interface ChatEntityChipProps extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"type"
> {
icon: IconName;
label: string;
}

export const ChatEntityChip = forwardRef<
HTMLButtonElement,
ChatEntityChipProps
>(function ChatEntityChip(
{ icon, label, draggable, className, ...buttonProps },
ref,
) {
return (
<button
ref={ref}
type="button"
draggable={draggable}
className={cn(
"inline-flex items-center gap-1 rounded-md border bg-background px-1.5 py-0.5 text-xs font-medium text-foreground hover:bg-accent transition-colors align-middle",
draggable ? "cursor-grab" : "cursor-pointer",
className,
)}
{...buttonProps}
>
<Icon name={icon} className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate max-w-[160px]">{label}</span>
</button>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { type KeyboardEvent, useState } from "react";

import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import { InlineStack } from "@/components/ui/layout";
import { Textarea } from "@/components/ui/textarea";

interface ChatInputProps {
isPending: boolean;
onSubmit: (prompt: string) => void;
}

export function ChatInput({ isPending, onSubmit }: ChatInputProps) {
const [prompt, setPrompt] = useState("");

function handleSubmit() {
const trimmed = prompt.trim();
if (!trimmed || isPending) return;
setPrompt("");
onSubmit(trimmed);
}

function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}

return (
<InlineStack gap="2" className="border-t p-2 w-full" blockAlign="start">
<Textarea
className="flex-1 resize-none max-h-32 overflow-y-auto"
rows={2}
placeholder="Ask about your pipeline..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isPending}
/>
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled={isPending || !prompt.trim()}
aria-label="Send message"
>
<Icon name={isPending ? "Loader" : "Send"} />
</Button>
</InlineStack>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Text } from "@/components/ui/typography";
import type { ChatMessage as ChatMessageType } from "@/routes/v2/pages/Editor/components/AiChat/aiChat.types";

import { MessageBubble } from "./MessageBubble";
import { renderMarkdown } from "./renderMarkdown";

interface ChatMessageProps {
message: ChatMessageType;
}

export function ChatMessage({ message }: ChatMessageProps) {
const isUser = message.role === "user";

return (
<MessageBubble variant={isUser ? "user" : "assistant"} gap="1">
{isUser ? (
<Text size="sm" className="break-words">
{message.content}
</Text>
) : (
<div className="text-sm break-words min-w-0 overflow-x-auto">
{renderMarkdown(message.content, message.componentReferences)}
</div>
)}
</MessageBubble>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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/aiChat.types";

import { ChatMessage } from "./ChatMessage";
import { ThinkingMessage } from "./ThinkingMessage";

interface ChatMessageListProps {
messages: ChatMessageType[];
thinkingText?: string | null;
}

export function ChatMessageList({
messages,
thinkingText,
}: ChatMessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null);

useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages.length, thinkingText]);

if (messages.length === 0 && !thinkingText) {
return (
<BlockStack align="center" inlineAlign="center" className="flex-1 p-4">
<Text size="sm" tone="subdued">
Ask anything about your pipeline
</Text>
</BlockStack>
);
}

return (
<BlockStack gap="2" className="flex-1 overflow-y-auto p-3">
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
))}
{thinkingText && <ThinkingMessage text={thinkingText} />}
<div ref={bottomRef} />
</BlockStack>
);
}
Loading
Loading