diff --git a/clients/web/src/App.css b/clients/web/src/App.css index 0c064dd19..ab0135b0d 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -59,6 +59,19 @@ /* ── History ───────────────────────────────────────────── */ --inspector-history-success: var(--mantine-color-green-6); --inspector-history-error: var(--mantine-color-red-6); + + /* ── Protocol direction (Protocol Builder) ─────────────── */ + --inspector-protocol-send-text: var(--mantine-color-green-7); + --inspector-protocol-send-bg: var(--mantine-color-green-0); + --inspector-protocol-send-border: var(--mantine-color-green-3); + --inspector-protocol-receive-text: var(--mantine-color-blue-7); + --inspector-protocol-receive-bg: var(--mantine-color-blue-0); + --inspector-protocol-receive-border: var(--mantine-color-blue-3); + --inspector-protocol-recursion-text: var(--mantine-color-yellow-8); + --inspector-protocol-recursion-bg: var(--mantine-color-yellow-0); + --inspector-protocol-recursion-border: var(--mantine-color-yellow-3); + --inspector-protocol-target-bg: var(--mantine-color-blue-0); + --inspector-protocol-target-border: var(--mantine-color-blue-4); } /* Dark mode overrides — tokens that need adjustment in dark scheme */ @@ -67,6 +80,18 @@ --inspector-surface-code: var(--mantine-color-dark-9); --inspector-border-subtle: var(--mantine-color-gray-4); --inspector-input-background: var(--mantine-color-dark-9); + + --inspector-protocol-send-text: var(--mantine-color-green-3); + --inspector-protocol-send-bg: var(--mantine-color-green-9); + --inspector-protocol-send-border: var(--mantine-color-green-7); + --inspector-protocol-receive-text: var(--mantine-color-blue-3); + --inspector-protocol-receive-bg: var(--mantine-color-blue-9); + --inspector-protocol-receive-border: var(--mantine-color-blue-7); + --inspector-protocol-recursion-text: var(--mantine-color-yellow-3); + --inspector-protocol-recursion-bg: var(--mantine-color-yellow-9); + --inspector-protocol-recursion-border: var(--mantine-color-yellow-7); + --inspector-protocol-target-bg: var(--mantine-color-blue-9); + --inspector-protocol-target-border: var(--mantine-color-blue-5); } /* ── Animations ──────────────────────────────────────────── */ diff --git a/clients/web/src/components/groups/ProtocolOutputPanel/ProtocolOutputPanel.stories.tsx b/clients/web/src/components/groups/ProtocolOutputPanel/ProtocolOutputPanel.stories.tsx new file mode 100644 index 000000000..11b13be4d --- /dev/null +++ b/clients/web/src/components/groups/ProtocolOutputPanel/ProtocolOutputPanel.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { Card, Stack } from "@mantine/core"; +import { ProtocolOutputPanel } from "./ProtocolOutputPanel"; + +const meta: Meta = { + title: "Groups/ProtocolOutputPanel", + component: ProtocolOutputPanel, + parameters: { layout: "padded" }, + args: { + pythonSnippet: + 'from llmsessioncontract import Monitor\n\nprotocol = "!Search.?SearchResult.end"\nmonitor = Monitor(protocol)', + copied: null, + onCopyDsl: fn(), + onCopyPython: fn(), + onDownload: fn(), + }, + render: (args) => ( + + + + + + ), +}; + +export default meta; +type Story = StoryObj; + +export const SimpleSequence: Story = { + args: { protocol: "!Search.?SearchResult.!Book.?BookConfirm.end" }, +}; + +export const Choice: Story = { + args: { protocol: "!{Yes.!Confirm.end, No.end}" }, +}; + +export const Recursion: Story = { + args: { protocol: "rec X.!Ping.?Pong.X" }, +}; + +export const Empty: Story = { + args: { protocol: "end" }, +}; + +export const Copied: Story = { + args: { + protocol: "!Search.?SearchResult.end", + copied: "dsl", + }, +}; diff --git a/clients/web/src/components/groups/ProtocolOutputPanel/ProtocolOutputPanel.test.tsx b/clients/web/src/components/groups/ProtocolOutputPanel/ProtocolOutputPanel.test.tsx new file mode 100644 index 000000000..4156d1356 --- /dev/null +++ b/clients/web/src/components/groups/ProtocolOutputPanel/ProtocolOutputPanel.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { ProtocolOutputPanel } from "./ProtocolOutputPanel"; + +function makeProps( + overrides: Partial> = {}, +) { + return { + protocol: "!Search.?SearchResult.end", + pythonSnippet: "from llmsessioncontract import Monitor", + copied: null as "dsl" | "python" | null, + onCopyDsl: vi.fn(), + onCopyPython: vi.fn(), + onDownload: vi.fn(), + ...overrides, + }; +} + +describe("ProtocolOutputPanel", () => { + it("renders the DSL, FSM preview, and Python snippet", () => { + renderWithMantine(); + expect(screen.getByText("Session Type DSL")).toBeInTheDocument(); + expect(screen.getByText("State Machine Preview")).toBeInTheDocument(); + expect(screen.getByText("Python Integration")).toBeInTheDocument(); + expect( + screen.getByText("from llmsessioncontract import Monitor"), + ).toBeInTheDocument(); + }); + + it("invokes the copy callbacks when Copy buttons are clicked", async () => { + const onCopyDsl = vi.fn(); + const onCopyPython = vi.fn(); + renderWithMantine( + , + ); + const user = userEvent.setup(); + const copyButtons = screen.getAllByRole("button", { name: /^Copy$/ }); + await user.click(copyButtons[0]); + await user.click(copyButtons[1]); + expect(onCopyDsl).toHaveBeenCalled(); + expect(onCopyPython).toHaveBeenCalled(); + }); + + it("shows 'Copied!' on the DSL button when copied is 'dsl'", () => { + renderWithMantine( + , + ); + const buttons = screen.getAllByRole("button"); + const labels = buttons.map((b) => b.textContent ?? ""); + expect(labels).toContain("Copied!"); + }); + + it("shows 'Copied!' on the Python button when copied is 'python'", () => { + renderWithMantine( + , + ); + const buttons = screen.getAllByRole("button"); + const labels = buttons.map((b) => b.textContent ?? ""); + expect(labels).toContain("Copied!"); + }); + + it("invokes onDownload when the download button is clicked", async () => { + const onDownload = vi.fn(); + renderWithMantine(); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: /Download Python File/ }), + ); + expect(onDownload).toHaveBeenCalled(); + }); + + it("renders the empty FSM hint for an empty protocol", () => { + renderWithMantine( + , + ); + expect( + screen.getByText("Add steps to see the state machine"), + ).toBeInTheDocument(); + }); + + it("renders FSM transitions for a non-trivial protocol", () => { + renderWithMantine( + , + ); + // The choice fans out into two outgoing edges, both rendered as pills + expect(screen.getAllByText(/!Yes|!No/).length).toBeGreaterThan(0); + }); + + it("renders a loop transition for recursion", () => { + renderWithMantine( + , + ); + expect(screen.getByText(/↻X/)).toBeInTheDocument(); + }); + + it("highlights protocol tokens by category", () => { + renderWithMantine( + , + ); + expect(screen.getAllByText("rec").length).toBeGreaterThan(0); + expect(screen.getByText("end")).toBeInTheDocument(); + expect(screen.getAllByText("!").length).toBeGreaterThan(0); + expect(screen.getAllByText("?").length).toBeGreaterThan(0); + }); + + it("highlights spaces and other characters without crashing", () => { + renderWithMantine( + , + ); + expect(screen.getByText("end")).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/ProtocolOutputPanel/ProtocolOutputPanel.tsx b/clients/web/src/components/groups/ProtocolOutputPanel/ProtocolOutputPanel.tsx new file mode 100644 index 000000000..cf2f49711 --- /dev/null +++ b/clients/web/src/components/groups/ProtocolOutputPanel/ProtocolOutputPanel.tsx @@ -0,0 +1,302 @@ +import { Button, Group, Paper, ScrollArea, Stack, Text } from "@mantine/core"; +import { MdContentCopy, MdDownload } from "react-icons/md"; +import { + parseToFSM, + type FSMTransition, +} from "../../screens/ProtocolBuilderScreen/protocol"; + +export interface ProtocolOutputPanelProps { + protocol: string; + pythonSnippet: string; + copied: "dsl" | "python" | null; + onCopyDsl: () => void; + onCopyPython: () => void; + onDownload: () => void; +} + +const SectionLabel = Text.withProps({ + size: "xs", + fw: 600, + tt: "uppercase", + c: "dimmed", +}); + +const HeaderRow = Group.withProps({ + justify: "space-between", + align: "center", +}); + +const CopyButton = Button.withProps({ + variant: "subtle", + size: "compact-xs", +}); + +const CodeBlock = Paper.withProps({ + variant: "code", + withBorder: true, +}); + +const PythonScroll = ScrollArea.Autosize.withProps({ + mah: 240, +}); + +const StateBadge = Paper.withProps({ + withBorder: true, + px: 6, + py: 2, + radius: "sm", + bg: "var(--inspector-surface-card)", +}); + +const TransitionPill = Paper.withProps({ + px: 6, + py: 2, + radius: "sm", +}); + +export function ProtocolOutputPanel({ + protocol, + pythonSnippet, + copied, + onCopyDsl, + onCopyPython, + onDownload, +}: ProtocolOutputPanelProps) { + return ( + + + + Session Type DSL + } + > + {copied === "dsl" ? "Copied!" : "Copy"} + + + + + + + + + State Machine Preview + + + + + + + + Python Integration + } + > + {copied === "python" ? "Copied!" : "Copy"} + + + + + + {pythonSnippet} + + + + + + + + ); +} + +interface ProtocolHighlightProps { + protocol: string; +} + +function ProtocolHighlight({ protocol }: ProtocolHighlightProps) { + const tokens: { text: string; color?: string; italic?: boolean }[] = []; + let i = 0; + const src = protocol; + const isWordChar = (c: string): boolean => /[a-zA-Z0-9_-]/.test(c); + + while (i < src.length) { + const c = src[i]; + if (c === "!") { + tokens.push({ text: "!", color: "var(--inspector-protocol-send-text)" }); + i += 1; + } else if (c === "?") { + tokens.push({ + text: "?", + color: "var(--inspector-protocol-receive-text)", + }); + i += 1; + } else if (c === "." || c === "{" || c === "}" || c === ",") { + tokens.push({ text: c, color: "var(--inspector-text-secondary)" }); + i += 1; + } else if ( + src.slice(i, i + 3) === "rec" && + (i + 3 >= src.length || !isWordChar(src[i + 3])) + ) { + tokens.push({ + text: "rec", + color: "var(--inspector-protocol-recursion-text)", + italic: true, + }); + i += 3; + } else if ( + src.slice(i, i + 3) === "end" && + (i + 3 >= src.length || !isWordChar(src[i + 3])) + ) { + tokens.push({ + text: "end", + color: "var(--inspector-text-secondary)", + italic: true, + }); + i += 3; + } else if (isWordChar(c)) { + const start = i; + while (i < src.length && isWordChar(src[i])) i += 1; + tokens.push({ + text: src.slice(start, i), + color: "var(--inspector-text-primary)", + }); + } else { + tokens.push({ text: c }); + i += 1; + } + } + + return ( + <> + {tokens.map((t, idx) => ( + + {t.text} + + ))} + + ); +} + +interface StateMachinePreviewProps { + protocol: string; +} + +function StateMachinePreview({ protocol }: StateMachinePreviewProps) { + let result; + try { + result = parseToFSM(protocol); + } catch { + return ( + + Could not parse the current protocol + + ); + } + const { states, transitions, endStates } = result; + if (transitions.length === 0) { + return ( + + Add steps to see the state machine + + ); + } + + const bySource = new Map(); + for (const t of transitions) { + const arr = bySource.get(t.from) ?? []; + arr.push(t); + bySource.set(t.from, arr); + } + const allStates = Array.from(states).sort((a, b) => a - b); + + return ( + + {allStates.map((s) => { + const outgoing = bySource.get(s); + if (!outgoing || outgoing.length === 0) { + if (endStates.has(s)) { + return ( + + + + S{s} + + + + end + + + ); + } + return null; + } + return ( + + + + S{s} + + + + → + + + {outgoing.map((t, idx) => ( + + + + {transitionGlyph(t.dir)} + {t.label} + + + + → + + + + S{t.to} + + + + ))} + + + ); + })} + + ); +} + +function transitionBg(dir: FSMTransition["dir"]): string { + if (dir === "send") return "var(--inspector-protocol-send-bg)"; + if (dir === "receive") return "var(--inspector-protocol-receive-bg)"; + return "var(--inspector-protocol-recursion-bg)"; +} + +function transitionColor(dir: FSMTransition["dir"]): string { + if (dir === "send") return "var(--inspector-protocol-send-text)"; + if (dir === "receive") return "var(--inspector-protocol-receive-text)"; + return "var(--inspector-protocol-recursion-text)"; +} + +function transitionGlyph(dir: FSMTransition["dir"]): string { + if (dir === "send") return "!"; + if (dir === "receive") return "?"; + return "↻"; +} diff --git a/clients/web/src/components/groups/ProtocolPaletteSidebar/ProtocolPaletteSidebar.stories.tsx b/clients/web/src/components/groups/ProtocolPaletteSidebar/ProtocolPaletteSidebar.stories.tsx new file mode 100644 index 000000000..73b6ab2aa --- /dev/null +++ b/clients/web/src/components/groups/ProtocolPaletteSidebar/ProtocolPaletteSidebar.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { Card, Stack } from "@mantine/core"; +import { ProtocolPaletteSidebar } from "./ProtocolPaletteSidebar"; +import { flightBookingTools } from "../../screens/ProtocolBuilderScreen/ProtocolBuilderScreen.fixtures"; + +const meta: Meta = { + title: "Groups/ProtocolPaletteSidebar", + component: ProtocolPaletteSidebar, + parameters: { layout: "padded" }, + args: { + tools: flightBookingTools, + recVars: [], + listChanged: false, + targetTerminated: false, + targetLabel: null, + onRefreshTools: fn(), + onClearTarget: fn(), + onAddTool: fn(), + onAddPair: fn(), + onAddInternalChoice: fn(), + onAddExternalChoice: fn(), + onAddRecursion: fn(), + onAddRecRef: fn(), + }, + render: (args) => ( + + + + + + ), +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { args: { tools: [] } }; + +export const WithRecVars: Story = { + args: { recVars: ["X", "Y"] }, +}; + +export const WithInsertTarget: Story = { + args: { targetLabel: "BranchA" }, +}; + +export const TargetTerminated: Story = { + args: { targetTerminated: true, targetLabel: "BranchA" }, +}; diff --git a/clients/web/src/components/groups/ProtocolPaletteSidebar/ProtocolPaletteSidebar.test.tsx b/clients/web/src/components/groups/ProtocolPaletteSidebar/ProtocolPaletteSidebar.test.tsx new file mode 100644 index 000000000..25daa4b3b --- /dev/null +++ b/clients/web/src/components/groups/ProtocolPaletteSidebar/ProtocolPaletteSidebar.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { ProtocolPaletteSidebar } from "./ProtocolPaletteSidebar"; + +const tools: Tool[] = [ + { name: "search", inputSchema: { type: "object" } }, + { name: "book", inputSchema: { type: "object" } }, +]; + +function makeProps( + overrides: Partial> = {}, +) { + return { + tools, + recVars: [], + listChanged: false, + targetTerminated: false, + targetLabel: null, + onRefreshTools: vi.fn(), + onClearTarget: vi.fn(), + onAddTool: vi.fn(), + onAddPair: vi.fn(), + onAddInternalChoice: vi.fn(), + onAddExternalChoice: vi.fn(), + onAddRecursion: vi.fn(), + onAddRecRef: vi.fn(), + ...overrides, + }; +} + +describe("ProtocolPaletteSidebar", () => { + it("renders the empty state and offers a List Tools button", async () => { + const onRefreshTools = vi.fn(); + renderWithMantine( + , + ); + expect(screen.getByText("No tools discovered yet")).toBeInTheDocument(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "List Tools" })); + expect(onRefreshTools).toHaveBeenCalled(); + }); + + it("invokes onAddTool when a tool button is clicked", async () => { + const onAddTool = vi.fn(); + renderWithMantine(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /search/ })); + expect(onAddTool).toHaveBeenCalledWith(tools[0]); + }); + + it("invokes the construct callbacks", async () => { + const onAddPair = vi.fn(); + const onAddInternalChoice = vi.fn(); + const onAddExternalChoice = vi.fn(); + const onAddRecursion = vi.fn(); + renderWithMantine( + , + ); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: /Send \/ Receive Pair/ }), + ); + await user.click(screen.getByRole("button", { name: /Internal Choice/ })); + await user.click(screen.getByRole("button", { name: /External Choice/ })); + await user.click(screen.getByRole("button", { name: /Recursion/ })); + expect(onAddPair).toHaveBeenCalled(); + expect(onAddInternalChoice).toHaveBeenCalled(); + expect(onAddExternalChoice).toHaveBeenCalled(); + expect(onAddRecursion).toHaveBeenCalled(); + }); + + it("renders loop-back buttons for each rec var and fires onAddRecRef", async () => { + const onAddRecRef = vi.fn(); + renderWithMantine( + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /Loop back to X/ })); + await user.click(screen.getByRole("button", { name: /Loop back to Y/ })); + expect(onAddRecRef).toHaveBeenNthCalledWith(1, "X"); + expect(onAddRecRef).toHaveBeenNthCalledWith(2, "Y"); + }); + + it("disables tool and construct buttons when targetTerminated is true", () => { + renderWithMantine( + , + ); + const toolBtn = screen.getByRole("button", { name: /search/ }); + expect(toolBtn).toBeDisabled(); + const pairBtn = screen.getByRole("button", { + name: /Send \/ Receive Pair/, + }); + expect(pairBtn).toBeDisabled(); + }); + + it("renders the insert-target banner and clears it on click", async () => { + const onClearTarget = vi.fn(); + renderWithMantine( + , + ); + expect(screen.getByText("BranchA")).toBeInTheDocument(); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "Clear insert target" }), + ); + expect(onClearTarget).toHaveBeenCalled(); + }); + + it("renders the list-changed indicator when listChanged is true", () => { + renderWithMantine( + , + ); + expect(screen.getByText("List updated")).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/ProtocolPaletteSidebar/ProtocolPaletteSidebar.tsx b/clients/web/src/components/groups/ProtocolPaletteSidebar/ProtocolPaletteSidebar.tsx new file mode 100644 index 000000000..aeeb2307a --- /dev/null +++ b/clients/web/src/components/groups/ProtocolPaletteSidebar/ProtocolPaletteSidebar.tsx @@ -0,0 +1,209 @@ +import { Button, Group, Paper, ScrollArea, Stack, Text } from "@mantine/core"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { MdAdd, MdClose, MdCenterFocusStrong } from "react-icons/md"; +import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; + +export interface ProtocolPaletteSidebarProps { + tools: Tool[]; + recVars: string[]; + listChanged: boolean; + targetTerminated: boolean; + targetLabel: string | null; + onRefreshTools: () => void; + onClearTarget: () => void; + onAddTool: (tool: Tool) => void; + onAddPair: () => void; + onAddInternalChoice: () => void; + onAddExternalChoice: () => void; + onAddRecursion: () => void; + onAddRecRef: (varName: string) => void; +} + +const HeaderRow = Group.withProps({ + justify: "space-between", + align: "center", +}); + +const SectionLabel = Text.withProps({ + size: "xs", + fw: 600, + tt: "uppercase", + c: "dimmed", +}); + +const TargetBanner = Paper.withProps({ + p: "xs", + withBorder: true, + bg: "var(--inspector-protocol-target-bg)", + bd: "1px solid var(--inspector-protocol-target-border)", +}); + +const ToolButton = Button.withProps({ + variant: "default", + size: "sm", + fullWidth: true, + justify: "flex-start", +}); + +const ConstructButton = Button.withProps({ + variant: "default", + size: "sm", + fullWidth: true, + justify: "flex-start", +}); + +const SendGlyph = Text.withProps({ + span: true, + ff: "monospace", + c: "var(--inspector-protocol-send-text)", + fw: 700, +}); + +const ReceiveGlyph = Text.withProps({ + span: true, + ff: "monospace", + c: "var(--inspector-protocol-receive-text)", + fw: 700, +}); + +const RecursionGlyph = Text.withProps({ + span: true, + ff: "monospace", + c: "var(--inspector-protocol-recursion-text)", + fw: 700, +}); + +const PALETTE_MAX_HEIGHT = + "calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)"; + +export function ProtocolPaletteSidebar({ + tools, + recVars, + listChanged, + targetTerminated, + targetLabel, + onRefreshTools, + onClearTarget, + onAddTool, + onAddPair, + onAddInternalChoice, + onAddExternalChoice, + onAddRecursion, + onAddRecRef, +}: ProtocolPaletteSidebarProps) { + return ( + + + + + MCP Tools + + + + + {targetLabel ? ( + + + + + Adding to: {targetLabel} + + + + + ) : null} + + {tools.length === 0 ? ( + + + No tools discovered yet + + + + ) : ( + + Available Tools ({tools.length}) + {tools.map((tool) => ( + onAddTool(tool)} + rightSection={} + > + + {tool.name} + + + ))} + + )} + + + Protocol Constructs + + ! + ? + + } + > + Send / Receive Pair + + !{"{}"}} + > + Internal Choice + + ?{"{}"}} + > + External Choice + + rec} + > + Recursion + + {recVars.map((v) => ( + + ))} + + + + ); +} diff --git a/clients/web/src/components/groups/ProtocolStepList/ProtocolStepList.stories.tsx b/clients/web/src/components/groups/ProtocolStepList/ProtocolStepList.stories.tsx new file mode 100644 index 000000000..84c29ef69 --- /dev/null +++ b/clients/web/src/components/groups/ProtocolStepList/ProtocolStepList.stories.tsx @@ -0,0 +1,100 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { Card, Stack } from "@mantine/core"; +import { ProtocolStepList } from "./ProtocolStepList"; +import type { ProtocolStep } from "../../screens/ProtocolBuilderScreen/protocol"; +import { flightBookingTools } from "../../screens/ProtocolBuilderScreen/ProtocolBuilderScreen.fixtures"; + +const meta: Meta = { + title: "Groups/ProtocolStepList", + component: ProtocolStepList, + parameters: { layout: "padded" }, + args: { + tools: flightBookingTools, + receiveOptions: ["search_flightsResult", "search_flightsError"], + insertTarget: null, + onSetInsertTarget: fn(), + onUpdateStep: fn(), + onRemoveStep: fn(), + onConvertToChoice: fn(), + }, + render: (args) => ( + + + + + + ), +}; + +export default meta; +type Story = StoryObj; + +const pair: ProtocolStep[] = [ + { + id: "s1", + type: "action", + direction: "send", + label: "search_flights", + pairId: "p1", + }, + { + id: "r1", + type: "action", + direction: "receive", + label: "search_flightsResult", + pairId: "p1", + }, +]; + +const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "send", + branches: [ + { id: "b1", label: "Confirm", steps: [] }, + { id: "b2", label: "Cancel", steps: [] }, + ], +}; + +const recursion: ProtocolStep[] = [ + { id: "rec1", type: "recursion", recVar: "X" }, + ...pair.map((s) => ({ ...s, id: `${s.id}-r` })), + { id: "ref1", type: "action", isRecRef: true, recVar: "X" }, +]; + +export const PairedSteps: Story = { args: { steps: pair } }; + +export const Choice: Story = { args: { steps: [choice] } }; + +export const Recursion: Story = { args: { steps: recursion } }; + +export const NestedChoice: Story = { + args: { + steps: [ + { + id: "outer", + type: "choice", + direction: "receive", + branches: [ + { + id: "ob1", + label: "Ok", + steps: [ + { + id: "inner", + type: "choice", + direction: "send", + branches: [ + { id: "ib1", label: "RetryA", steps: [] }, + { id: "ib2", label: "RetryB", steps: [] }, + ], + }, + ], + }, + { id: "ob2", label: "Err", steps: [] }, + ], + }, + ], + }, +}; diff --git a/clients/web/src/components/groups/ProtocolStepList/ProtocolStepList.test.tsx b/clients/web/src/components/groups/ProtocolStepList/ProtocolStepList.test.tsx new file mode 100644 index 000000000..938ee1b18 --- /dev/null +++ b/clients/web/src/components/groups/ProtocolStepList/ProtocolStepList.test.tsx @@ -0,0 +1,665 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import userEvent from "@testing-library/user-event"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { ProtocolStepList } from "./ProtocolStepList"; +import { + resetUid, + type ProtocolStep, +} from "../../screens/ProtocolBuilderScreen/protocol"; + +const tools: Tool[] = [ + { name: "search", inputSchema: { type: "object" } }, + { name: "book", inputSchema: { type: "object" } }, +]; + +beforeEach(() => { + resetUid(); +}); + +function makeProps( + steps: ProtocolStep[], + overrides: Partial> = {}, +) { + return { + steps, + tools, + receiveOptions: ["searchResult", "searchError"], + insertTarget: null, + onSetInsertTarget: vi.fn(), + onUpdateStep: vi.fn(), + onRemoveStep: vi.fn(), + onConvertToChoice: vi.fn(), + ...overrides, + }; +} + +describe("ProtocolStepList", () => { + it("renders an empty list with no children", () => { + const { container } = renderWithMantine( + , + ); + // Stack should still render but have no step children + expect(container.querySelectorAll("[data-step]").length).toBe(0); + }); + + it("renders a paired send/receive when two steps share a pairId", () => { + const send: ProtocolStep = { + id: "s1", + type: "action", + direction: "send", + label: "search", + pairId: "p1", + }; + const recv: ProtocolStep = { + id: "r1", + type: "action", + direction: "receive", + label: "searchResult", + pairId: "p1", + }; + renderWithMantine(); + expect(screen.getByText("paired")).toBeInTheDocument(); + }); + + it("triggers onUpdateStep on both halves of a pair when the send label changes", async () => { + const onUpdateStep = vi.fn(); + const send: ProtocolStep = { + id: "s1", + type: "action", + direction: "send", + label: "search", + pairId: "p1", + }; + const recv: ProtocolStep = { + id: "r1", + type: "action", + direction: "receive", + label: "searchResult", + pairId: "p1", + }; + renderWithMantine( + , + ); + // tools=[] means LabelEditor uses TextInput, easier to type into + const inputs = screen.getAllByLabelText(/Send label|Receive label/); + const sendInput = inputs.find( + (el) => (el as HTMLInputElement).value === "search", + ) as HTMLInputElement; + const user = userEvent.setup(); + await user.clear(sendInput); + await user.type(sendInput, "Z"); + // Each keystroke fires onUpdateStep twice (once for send, once for paired + // receive). After clear+type "Z" we expect the receive to have been + // updated at least once with the synthetic ZResult label. + expect(onUpdateStep).toHaveBeenCalled(); + }); + + it("invokes onConvertToChoice from the pair's send-side button", async () => { + const onConvertToChoice = vi.fn(); + const send: ProtocolStep = { + id: "s1", + type: "action", + direction: "send", + label: "search", + pairId: "p1", + }; + const recv: ProtocolStep = { + id: "r1", + type: "action", + direction: "receive", + label: "searchResult", + pairId: "p1", + }; + renderWithMantine( + , + ); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "Convert to internal choice" }), + ); + expect(onConvertToChoice).toHaveBeenCalledWith( + "s1", + "p1", + "send", + expect.any(Array), + ); + }); + + it("invokes onConvertToChoice from the pair's receive-side button", async () => { + const onConvertToChoice = vi.fn(); + const send: ProtocolStep = { + id: "s1", + type: "action", + direction: "send", + label: "search", + pairId: "p1", + }; + const recv: ProtocolStep = { + id: "r1", + type: "action", + direction: "receive", + label: "searchResult", + pairId: "p1", + }; + renderWithMantine( + , + ); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "Convert to external choice" }), + ); + expect(onConvertToChoice).toHaveBeenCalledWith("r1", "p1", "receive", [ + "searchResult", + "searchError", + ]); + }); + + it("falls back to a synthetic branch label set when no other tools exist", async () => { + const onConvertToChoice = vi.fn(); + const send: ProtocolStep = { + id: "s1", + type: "action", + direction: "send", + label: "only", + pairId: "p1", + }; + const recv: ProtocolStep = { + id: "r1", + type: "action", + direction: "receive", + label: "onlyResult", + pairId: "p1", + }; + renderWithMantine( + , + ); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "Convert to internal choice" }), + ); + expect(onConvertToChoice).toHaveBeenCalledWith("s1", "p1", "send", [ + "only", + "onlyAlt", + ]); + }); + + it("calls onRemoveStep when the pair delete button is clicked", async () => { + const onRemoveStep = vi.fn(); + const send: ProtocolStep = { + id: "s1", + type: "action", + direction: "send", + label: "search", + pairId: "p1", + }; + const recv: ProtocolStep = { + id: "r1", + type: "action", + direction: "receive", + label: "searchResult", + pairId: "p1", + }; + renderWithMantine( + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Delete pair" })); + expect(onRemoveStep).toHaveBeenCalledWith("s1"); + }); + + it("renders a standalone send step", () => { + const step: ProtocolStep = { + id: "s1", + type: "action", + direction: "send", + label: "alone", + }; + renderWithMantine(); + expect( + screen.getByRole("button", { name: "Delete step" }), + ).toBeInTheDocument(); + }); + + it("renders a standalone receive step and a tool-bound annotation", () => { + const step: ProtocolStep = { + id: "s1", + type: "action", + direction: "receive", + label: "searchResult", + toolName: "search", + }; + renderWithMantine(); + expect(screen.getByText("(search)")).toBeInTheDocument(); + }); + + it("renders a recursion scope and removes it on delete", async () => { + const onRemoveStep = vi.fn(); + const step: ProtocolStep = { + id: "rec1", + type: "recursion", + recVar: "X", + }; + renderWithMantine( + , + ); + expect(screen.getByText("rec")).toBeInTheDocument(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Delete recursion" })); + expect(onRemoveStep).toHaveBeenCalledWith("rec1"); + }); + + it("renders a recursion reference and removes it on delete", async () => { + const onRemoveStep = vi.fn(); + const step: ProtocolStep = { + id: "ref1", + type: "action", + isRecRef: true, + recVar: "X", + }; + renderWithMantine( + , + ); + expect(screen.getByText(/loop → X/)).toBeInTheDocument(); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "Delete recursion ref" }), + ); + expect(onRemoveStep).toHaveBeenCalledWith("ref1"); + }); + + it("renders a choice with branches and supports adding a branch", async () => { + const onUpdateStep = vi.fn(); + const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "send", + branches: [ + { id: "b1", label: "BranchA", steps: [] }, + { id: "b2", label: "BranchB", steps: [] }, + ], + }; + renderWithMantine( + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Add branch" })); + expect(onUpdateStep).toHaveBeenCalledWith("c1", expect.any(Function)); + }); + + it("invokes onSetInsertTarget when a branch target is clicked", async () => { + const onSetInsertTarget = vi.fn(); + const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "receive", + branches: [ + { id: "b1", label: "BranchA", steps: [] }, + { id: "b2", label: "BranchB", steps: [] }, + ], + }; + renderWithMantine( + , + ); + const user = userEvent.setup(); + await user.click( + screen.getAllByRole("button", { + name: "Target this branch for palette insertion", + })[0], + ); + expect(onSetInsertTarget).toHaveBeenCalledWith({ + choiceStepId: "c1", + branchId: "b1", + }); + }); + + it("clears the insert target when the active branch is clicked again", async () => { + const onSetInsertTarget = vi.fn(); + const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "send", + branches: [ + { id: "b1", label: "BranchA", steps: [] }, + { id: "b2", label: "BranchB", steps: [] }, + ], + }; + renderWithMantine( + , + ); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "Stop targeting this branch" }), + ); + expect(onSetInsertTarget).toHaveBeenCalledWith(null); + }); + + it("only shows the remove-branch button when there are more than two branches", async () => { + const onUpdateStep = vi.fn(); + const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "send", + branches: [ + { id: "b1", label: "A", steps: [] }, + { id: "b2", label: "B", steps: [] }, + { id: "b3", label: "C", steps: [] }, + ], + }; + renderWithMantine( + , + ); + const removeButtons = screen.getAllByRole("button", { + name: "Remove branch", + }); + expect(removeButtons.length).toBe(3); + const user = userEvent.setup(); + await user.click(removeButtons[0]); + expect(onUpdateStep).toHaveBeenCalledWith("c1", expect.any(Function)); + }); + + it("collapses a branch when the chevron is clicked", async () => { + const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "send", + branches: [ + { + id: "b1", + label: "A", + steps: [ + { + id: "inner", + type: "action", + direction: "send", + label: "deep", + }, + ], + }, + { id: "b2", label: "B", steps: [] }, + ], + }; + renderWithMantine(); + const user = userEvent.setup(); + await user.click( + screen.getAllByRole("button", { name: "Collapse branch" })[0], + ); + // After collapsing, the inner step's delete button is no longer present + expect( + screen.queryByRole("button", { name: "Delete step" }), + ).not.toBeInTheDocument(); + }); + + it("removes a single step's delete works on a standalone action", async () => { + const onRemoveStep = vi.fn(); + const step: ProtocolStep = { + id: "x1", + type: "action", + direction: "send", + label: "x", + }; + renderWithMantine( + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Delete step" })); + expect(onRemoveStep).toHaveBeenCalledWith("x1"); + }); + + it("renders an active branch indicator when the insertTarget matches", () => { + const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "send", + branches: [ + { id: "b1", label: "A", steps: [] }, + { id: "b2", label: "B", steps: [] }, + ], + }; + renderWithMantine( + , + ); + expect( + screen.getByText("Use the palette to add steps here"), + ).toBeInTheDocument(); + }); + + it("shows 'end' when a branch is terminated by a rec ref", () => { + const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "send", + branches: [ + { + id: "b1", + label: "A", + steps: [{ id: "ref1", type: "action", isRecRef: true, recVar: "X" }], + }, + { id: "b2", label: "B", steps: [] }, + ], + }; + renderWithMantine(); + expect(screen.getByText("end")).toBeInTheDocument(); + }); + + it("renders a receive-direction choice header", () => { + const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "receive", + branches: [ + { id: "b1", label: "Ok", steps: [] }, + { id: "b2", label: "Err", steps: [] }, + ], + }; + renderWithMantine(); + expect(screen.getByText("External Choice")).toBeInTheDocument(); + }); + + it("renders a Select-based label editor when tools are present", () => { + const send: ProtocolStep = { + id: "s1", + type: "action", + direction: "send", + label: "search", + pairId: "p1", + }; + const recv: ProtocolStep = { + id: "r1", + type: "action", + direction: "receive", + label: "searchResult", + pairId: "p1", + }; + renderWithMantine(); + // Select renders as a combobox/role=textbox; pick the Send label one. + expect( + screen.getByRole("textbox", { name: "Send label" }), + ).toBeInTheDocument(); + }); + + it("falls back to a TextInput branch label when no options remain", () => { + const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "send", + branches: [ + { id: "b1", label: "Custom", steps: [] }, + { id: "b2", label: "B", steps: [] }, + ], + }; + // No tools and external direction is false ⇒ siblingLabels empty subset of empty options ⇒ TextInput + renderWithMantine( + , + ); + expect(screen.getAllByLabelText("Branch label").length).toBeGreaterThan(0); + }); + + it("re-expands a branch after collapsing it", async () => { + const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "send", + branches: [ + { + id: "b1", + label: "A", + steps: [ + { + id: "inner", + type: "action", + direction: "send", + label: "deep", + }, + ], + }, + { id: "b2", label: "B", steps: [] }, + ], + }; + renderWithMantine(); + const user = userEvent.setup(); + await user.click( + screen.getAllByRole("button", { name: "Collapse branch" })[0], + ); + await user.click( + screen.getAllByRole("button", { name: "Expand branch" })[0], + ); + expect( + screen.getByRole("button", { name: "Delete step" }), + ).toBeInTheDocument(); + }); + + it("updates a free-form (no-options) action label via the text input", async () => { + const onUpdateStep = vi.fn(); + const step: ProtocolStep = { + id: "x1", + type: "action", + direction: "send", + label: "x", + }; + renderWithMantine( + , + ); + const user = userEvent.setup(); + const input = screen.getByLabelText("Send label"); + await user.type(input, "y"); + expect(onUpdateStep).toHaveBeenCalled(); + }); + + it("removes a choice via Delete choice", async () => { + const onRemoveStep = vi.fn(); + const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "send", + branches: [ + { id: "b1", label: "A", steps: [] }, + { id: "b2", label: "B", steps: [] }, + ], + }; + renderWithMantine( + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Delete choice" })); + expect(onRemoveStep).toHaveBeenCalledWith("c1"); + }); + + it("triggers the receive label change handler in a pair card", async () => { + const onUpdateStep = vi.fn(); + const send: ProtocolStep = { + id: "s1", + type: "action", + direction: "send", + label: "search", + pairId: "p1", + }; + const recv: ProtocolStep = { + id: "r1", + type: "action", + direction: "receive", + label: "searchResult", + pairId: "p1", + }; + renderWithMantine( + , + ); + const recvInput = screen.getByLabelText( + "Receive label", + ) as HTMLInputElement; + const user = userEvent.setup(); + await user.clear(recvInput); + await user.type(recvInput, "X"); + expect(onUpdateStep).toHaveBeenCalledWith("r1", expect.any(Function)); + }); + + it("invokes onRemoveBranch via the branch remove button", async () => { + const onUpdateStep = vi.fn(); + const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "send", + branches: [ + { id: "b1", label: "A", steps: [] }, + { id: "b2", label: "B", steps: [] }, + { id: "b3", label: "C", steps: [] }, + ], + }; + renderWithMantine( + , + ); + const user = userEvent.setup(); + const btn = screen.getAllByRole("button", { name: "Remove branch" })[0]; + await user.click(btn); + // The onUpdateStep updater should remove b1 + const lastCall = + onUpdateStep.mock.calls[onUpdateStep.mock.calls.length - 1]; + const updater = lastCall[1] as (s: ProtocolStep) => ProtocolStep; + const result = updater(choice); + expect(result.branches?.find((b) => b.id === "b1")).toBeUndefined(); + }); + + it("updates a free-form branch label via TextInput", async () => { + const onUpdateStep = vi.fn(); + const choice: ProtocolStep = { + id: "c1", + type: "choice", + direction: "send", + branches: [ + { id: "b1", label: "A", steps: [] }, + { id: "b2", label: "B", steps: [] }, + ], + }; + renderWithMantine( + , + ); + const user = userEvent.setup(); + const input = screen.getAllByLabelText("Branch label")[0]; + await user.type(input, "X"); + expect(onUpdateStep).toHaveBeenCalled(); + }); +}); diff --git a/clients/web/src/components/groups/ProtocolStepList/ProtocolStepList.tsx b/clients/web/src/components/groups/ProtocolStepList/ProtocolStepList.tsx new file mode 100644 index 000000000..40f4a8450 --- /dev/null +++ b/clients/web/src/components/groups/ProtocolStepList/ProtocolStepList.tsx @@ -0,0 +1,684 @@ +import { useState, type JSX } from "react"; +import { + ActionIcon, + Group, + Paper, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { + MdCallSplit, + MdCenterFocusStrong, + MdChevronRight, + MdDelete, + MdExpandMore, + MdAdd, +} from "react-icons/md"; +import { + isTerminated, + type Direction, + type InsertTarget, + type ProtocolBranch, + type ProtocolStep, +} from "../../screens/ProtocolBuilderScreen/protocol"; + +export interface ProtocolStepListProps { + steps: ProtocolStep[]; + tools: Tool[]; + receiveOptions: string[]; + insertTarget: InsertTarget | null; + onSetInsertTarget: (target: InsertTarget | null) => void; + onUpdateStep: ( + stepId: string, + updater: (step: ProtocolStep) => ProtocolStep, + ) => void; + onRemoveStep: (stepId: string) => void; + onConvertToChoice: ( + stepId: string, + pairId: string, + direction: Direction, + branchLabels: string[], + ) => void; +} + +const SendCard = Paper.withProps({ + withBorder: true, + p: "xs", + radius: "sm", + bg: "var(--inspector-protocol-send-bg)", + bd: "1px solid var(--inspector-protocol-send-border)", +}); + +const ReceiveCard = Paper.withProps({ + withBorder: true, + p: "xs", + radius: "sm", + bg: "var(--inspector-protocol-receive-bg)", + bd: "1px solid var(--inspector-protocol-receive-border)", +}); + +const RecursionCard = Paper.withProps({ + withBorder: true, + p: "xs", + radius: "sm", + bg: "var(--inspector-protocol-recursion-bg)", + bd: "1px solid var(--inspector-protocol-recursion-border)", +}); + +const ChoiceCard = Paper.withProps({ + withBorder: true, + p: 0, + radius: "sm", +}); + +const ChoiceHeaderRow = Group.withProps({ + gap: "xs", + px: "xs", + py: 6, + wrap: "nowrap", +}); + +const PairContainer = Paper.withProps({ + withBorder: true, + p: 0, + radius: "sm", +}); + +const Glyph = Text.withProps({ + span: true, + ff: "monospace", + size: "xs", + fw: 700, +}); + +const RowGroup = Group.withProps({ + gap: "xs", + align: "center", + wrap: "nowrap", +}); + +const MonoInput = TextInput.withProps({ + flex: 1, + size: "xs", + styles: { + input: { fontFamily: "var(--mantine-font-family-monospace)" }, + }, +}); + +export function ProtocolStepList({ + steps, + tools, + receiveOptions, + insertTarget, + onSetInsertTarget, + onUpdateStep, + onRemoveStep, + onConvertToChoice, +}: ProtocolStepListProps) { + const rendered = new Set(); + const elements: JSX.Element[] = []; + + for (let i = 0; i < steps.length; i += 1) { + const step = steps[i]; + if (rendered.has(step.id)) continue; + rendered.add(step.id); + + const next = i + 1 < steps.length ? steps[i + 1] : undefined; + const isPaired = + step.type === "action" && + step.direction === "send" && + step.pairId !== undefined && + next?.pairId === step.pairId; + + if (isPaired && next) { + rendered.add(next.id); + elements.push( + , + ); + } else { + elements.push( + , + ); + } + } + + return {elements}; +} + +interface PairCardProps { + sendStep: ProtocolStep; + recvStep: ProtocolStep; + tools: Tool[]; + receiveOptions: string[]; + onUpdateStep: ProtocolStepListProps["onUpdateStep"]; + onRemoveStep: ProtocolStepListProps["onRemoveStep"]; + onConvertToChoice: ProtocolStepListProps["onConvertToChoice"]; +} + +function PairCard({ + sendStep, + recvStep, + tools, + receiveOptions, + onUpdateStep, + onRemoveStep, + onConvertToChoice, +}: PairCardProps) { + const handleSendChange = (value: string): void => { + onUpdateStep(sendStep.id, (s) => ({ ...s, label: value })); + onUpdateStep(recvStep.id, (s) => ({ ...s, label: `${value}Result` })); + }; + + return ( + + + + ! + t.name)} + onChange={handleSendChange} + placeholder="label" + ariaLabel="Send label" + /> + { + const label = sendStep.label || "Action"; + const others = tools + .map((t) => t.name) + .filter((n) => n !== label); + const branches = + others.length > 0 + ? [label, ...others.slice(0, 2)] + : [label, `${label}Alt`]; + onConvertToChoice( + sendStep.id, + sendStep.pairId ?? "", + "send", + branches, + ); + }} + aria-label="Convert to internal choice" + title="Convert to internal choice" + > + + + onRemoveStep(sendStep.id)} + aria-label="Delete pair" + > + + + + + + + ? + + onUpdateStep(recvStep.id, (s) => ({ ...s, label: v })) + } + placeholder="response label" + ariaLabel="Receive label" + /> + { + const sendLabel = sendStep.label || "Action"; + onConvertToChoice(recvStep.id, recvStep.pairId ?? "", "receive", [ + `${sendLabel}Result`, + `${sendLabel}Error`, + ]); + }} + aria-label="Convert to external choice" + title="Convert to external choice" + > + + + + paired + + + + + ); +} + +interface StepCardProps { + step: ProtocolStep; + tools: Tool[]; + receiveOptions: string[]; + insertTarget: InsertTarget | null; + onSetInsertTarget: ProtocolStepListProps["onSetInsertTarget"]; + onUpdateStep: ProtocolStepListProps["onUpdateStep"]; + onRemoveStep: ProtocolStepListProps["onRemoveStep"]; + onConvertToChoice: ProtocolStepListProps["onConvertToChoice"]; +} + +function StepCard({ + step, + tools, + receiveOptions, + insertTarget, + onSetInsertTarget, + onUpdateStep, + onRemoveStep, + onConvertToChoice, +}: StepCardProps) { + if (step.type === "action" && step.isRecRef) { + return ( + + + + ↻ loop → {step.recVar} + + + onRemoveStep(step.id)} + aria-label="Delete recursion ref" + > + + + + + + ); + } + + if (step.type === "action") { + const isSend = step.direction === "send"; + const Card = isSend ? SendCard : ReceiveCard; + const options = isSend ? tools.map((t) => t.name) : receiveOptions; + + return ( + + + + {isSend ? "!" : "?"} + + onUpdateStep(step.id, (s) => ({ ...s, label: v }))} + placeholder="label" + ariaLabel={isSend ? "Send label" : "Receive label"} + /> + {step.toolName ? ( + + ({step.toolName}) + + ) : null} + onRemoveStep(step.id)} + aria-label="Delete step" + > + + + + + ); + } + + if (step.type === "choice") { + const isSend = step.direction === "send"; + const prefixColor = isSend + ? "var(--inspector-protocol-send-text)" + : "var(--inspector-protocol-receive-text)"; + return ( + + + + {isSend ? "!" : "?"} + {"{"} + + + {isSend ? "Internal" : "External"} Choice + + + onUpdateStep(step.id, (s) => ({ + ...s, + branches: [ + ...(s.branches ?? []), + { + id: `step-branch-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + label: `Branch${String.fromCharCode(65 + (s.branches?.length ?? 0))}`, + steps: [], + }, + ], + })) + } + aria-label="Add branch" + > + + + onRemoveStep(step.id)} + aria-label="Delete choice" + > + + + + + {step.branches?.map((branch) => ( + { + if ((step.branches?.length ?? 0) > 2) { + onUpdateStep(step.id, (s) => ({ + ...s, + branches: s.branches?.filter((b) => b.id !== branch.id), + })); + } + }} + onUpdateBranchLabel={(label) => + onUpdateStep(step.id, (s) => ({ + ...s, + branches: s.branches?.map((b) => + b.id === branch.id ? { ...b, label } : b, + ), + })) + } + /> + ))} + + + {"}"} + + + ); + } + + if (step.type === "recursion") { + return ( + + + rec + + {step.recVar} + + . + + onRemoveStep(step.id)} + aria-label="Delete recursion" + > + + + + + + ); + } + + return null; +} + +interface BranchBlockProps { + branch: ProtocolBranch; + choiceStep: ProtocolStep; + tools: Tool[]; + receiveOptions: string[]; + insertTarget: InsertTarget | null; + onSetInsertTarget: ProtocolStepListProps["onSetInsertTarget"]; + onUpdateStep: ProtocolStepListProps["onUpdateStep"]; + onRemoveStep: ProtocolStepListProps["onRemoveStep"]; + onConvertToChoice: ProtocolStepListProps["onConvertToChoice"]; + onRemoveBranch: () => void; + onUpdateBranchLabel: (label: string) => void; +} + +const BranchPaper = Paper.withProps({ + withBorder: true, + p: 0, + radius: "sm", +}); + +function BranchBlock({ + branch, + choiceStep, + tools, + receiveOptions, + insertTarget, + onSetInsertTarget, + onUpdateStep, + onRemoveStep, + onConvertToChoice, + onRemoveBranch, + onUpdateBranchLabel, +}: BranchBlockProps) { + const [expanded, setExpanded] = useState(true); + const branchTerminated = isTerminated(branch.steps); + const isActive = + insertTarget?.choiceStepId === choiceStep.id && + insertTarget?.branchId === branch.id; + + const siblingLabels = (choiceStep.branches ?? []) + .filter((b) => b.id !== branch.id) + .map((b) => b.label); + const isExternal = choiceStep.direction === "receive"; + const allOptions = isExternal ? receiveOptions : tools.map((t) => t.name); + const branchOptions = allOptions.filter((o) => !siblingLabels.includes(o)); + + return ( + + + setExpanded(!expanded)} + aria-label={expanded ? "Collapse branch" : "Expand branch"} + > + {expanded ? : } + + + {branch.steps.length > 0 ? ( + + ({branch.steps.length}) + + ) : null} + + isActive + ? onSetInsertTarget(null) + : onSetInsertTarget({ + choiceStepId: choiceStep.id, + branchId: branch.id, + }) + } + aria-label={ + isActive + ? "Stop targeting this branch" + : "Target this branch for palette insertion" + } + > + + + {(choiceStep.branches?.length ?? 0) > 2 ? ( + + + + ) : null} + + + {expanded ? ( + + {branch.steps.length > 0 ? ( + + ) : null} + + {branchTerminated + ? "end" + : isActive + ? "Use the palette to add steps here" + : "Click the target icon to add steps from the palette"} + + + ) : null} + + ); +} + +interface LabelEditorProps { + value: string; + options: string[]; + onChange: (value: string) => void; + placeholder: string; + ariaLabel: string; +} + +function LabelEditor({ + value, + options, + onChange, + placeholder, + ariaLabel, +}: LabelEditorProps) { + if (options.length === 0) { + return ( + onChange(e.currentTarget.value)} + aria-label={ariaLabel} + /> + ); + } + const data = + value && !options.includes(value) ? [value, ...options] : options; + return ( + onChange(v ?? "")} + placeholder="branch label" + allowDeselect={false} + aria-label="Branch label" + /> + ); +} diff --git a/clients/web/src/components/screens/ProtocolBuilderScreen/ProtocolBuilderScreen.fixtures.ts b/clients/web/src/components/screens/ProtocolBuilderScreen/ProtocolBuilderScreen.fixtures.ts new file mode 100644 index 000000000..ce95e943e --- /dev/null +++ b/clients/web/src/components/screens/ProtocolBuilderScreen/ProtocolBuilderScreen.fixtures.ts @@ -0,0 +1,41 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +export const flightBookingTools: Tool[] = [ + { + name: "search_flights", + title: "Search Flights", + description: "Search available flights matching the given criteria", + inputSchema: { + type: "object", + properties: { + origin: { type: "string" }, + destination: { type: "string" }, + date: { type: "string", format: "date" }, + }, + required: ["origin", "destination", "date"], + }, + }, + { + name: "book_flight", + title: "Book Flight", + description: "Reserve a seat on the chosen flight", + inputSchema: { + type: "object", + properties: { + flightId: { type: "string" }, + passenger: { type: "string" }, + }, + required: ["flightId", "passenger"], + }, + }, + { + name: "cancel_booking", + title: "Cancel Booking", + description: "Cancel a confirmed booking", + inputSchema: { + type: "object", + properties: { bookingId: { type: "string" } }, + required: ["bookingId"], + }, + }, +]; diff --git a/clients/web/src/components/screens/ProtocolBuilderScreen/ProtocolBuilderScreen.stories.tsx b/clients/web/src/components/screens/ProtocolBuilderScreen/ProtocolBuilderScreen.stories.tsx new file mode 100644 index 000000000..863160833 --- /dev/null +++ b/clients/web/src/components/screens/ProtocolBuilderScreen/ProtocolBuilderScreen.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn, userEvent, within } from "storybook/test"; +import { ProtocolBuilderScreen } from "./ProtocolBuilderScreen"; +import { flightBookingTools } from "./ProtocolBuilderScreen.fixtures"; + +const meta: Meta = { + title: "Screens/ProtocolBuilderScreen", + component: ProtocolBuilderScreen, + parameters: { layout: "fullscreen" }, + args: { + listChanged: false, + onRefreshTools: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { tools: flightBookingTools }, +}; + +export const NoToolsYet: Story = { + args: { tools: [] }, +}; + +export const ToolListChanged: Story = { + args: { tools: flightBookingTools, listChanged: true }, +}; + +export const WithSimpleSequence: Story = { + args: { tools: flightBookingTools }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: /search_flights/ }), + ); + await userEvent.click( + await canvas.findByRole("button", { name: /book_flight/ }), + ); + }, +}; + +export const WithChoice: Story = { + args: { tools: flightBookingTools }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: /Internal Choice/ }), + ); + }, +}; + +export const WithRecursion: Story = { + args: { tools: flightBookingTools }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: /^rec Recursion$/ }), + ); + await userEvent.click( + await canvas.findByRole("button", { name: /search_flights/ }), + ); + await userEvent.click( + await canvas.findByRole("button", { name: /Loop back to X0/ }), + ); + }, +}; diff --git a/clients/web/src/components/screens/ProtocolBuilderScreen/ProtocolBuilderScreen.test.tsx b/clients/web/src/components/screens/ProtocolBuilderScreen/ProtocolBuilderScreen.test.tsx new file mode 100644 index 000000000..065aa0919 --- /dev/null +++ b/clients/web/src/components/screens/ProtocolBuilderScreen/ProtocolBuilderScreen.test.tsx @@ -0,0 +1,264 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import userEvent from "@testing-library/user-event"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { ProtocolBuilderScreen } from "./ProtocolBuilderScreen"; +import { resetUid } from "./protocol"; + +const tools: Tool[] = [ + { name: "search", inputSchema: { type: "object" } }, + { name: "book", inputSchema: { type: "object" } }, +]; + +const baseProps = { + tools, + listChanged: false, + onRefreshTools: vi.fn(), +}; + +beforeEach(() => { + resetUid(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("ProtocolBuilderScreen", () => { + it("renders the empty drop hint when no steps have been added", () => { + renderWithMantine(); + expect( + screen.getByText( + "Click tools or constructs on the left to build your protocol", + ), + ).toBeInTheDocument(); + }); + + it("renders all available MCP tools in the palette", () => { + renderWithMantine(); + expect(screen.getByText("search")).toBeInTheDocument(); + expect(screen.getByText("book")).toBeInTheDocument(); + expect(screen.getByText("Available Tools (2)")).toBeInTheDocument(); + }); + + it("adds a paired send/receive when a tool is clicked", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: /search/ })); + expect(screen.getAllByDisplayValue("search").length).toBeGreaterThan(0); + expect(screen.getAllByDisplayValue("searchResult").length).toBeGreaterThan( + 0, + ); + }); + + it("offers a List Tools button when the tools list is empty", async () => { + const user = userEvent.setup(); + const onRefreshTools = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "List Tools" })); + expect(onRefreshTools).toHaveBeenCalled(); + }); + + it("adds an internal choice and renders its branch labels", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: /Internal Choice/ })); + // The palette button and the choice header both read "Internal Choice" + expect(screen.getAllByText("Internal Choice").length).toBeGreaterThan(1); + expect( + screen.getAllByDisplayValue(/BranchA|BranchB/).length, + ).toBeGreaterThan(0); + }); + + it("adds an external choice", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: /External Choice/ })); + expect(screen.getAllByText("External Choice").length).toBeGreaterThan(1); + }); + + it("adds a recursion scope and exposes a loop-back palette button", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: /^rec Recursion$/ })); + expect( + screen.getByRole("button", { name: /Loop back to X0/ }), + ).toBeInTheDocument(); + }); + + it("adds a generic send/receive pair", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click( + screen.getByRole("button", { name: /Send \/ Receive Pair/ }), + ); + expect(screen.getAllByDisplayValue("Action").length).toBeGreaterThan(0); + expect(screen.getAllByDisplayValue("ActionResult").length).toBeGreaterThan( + 0, + ); + }); + + it("clears all steps when Clear is clicked", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click( + screen.getByRole("button", { name: /Send \/ Receive Pair/ }), + ); + await user.click(screen.getByRole("button", { name: /^Clear$/ })); + expect( + screen.getByText( + "Click tools or constructs on the left to build your protocol", + ), + ).toBeInTheDocument(); + }); + + it("copies the DSL to the clipboard and flashes 'Copied!'", async () => { + const writeText = vi + .spyOn(navigator.clipboard, "writeText") + .mockResolvedValue(undefined); + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: /search/ })); + const copyDsl = screen.getAllByRole("button", { name: /^Copy$/ })[0]; + await user.click(copyDsl); + expect(writeText).toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument(); + }); + + it("copies the Python snippet via the second copy button", async () => { + const writeText = vi + .spyOn(navigator.clipboard, "writeText") + .mockResolvedValue(undefined); + const user = userEvent.setup(); + renderWithMantine(); + const copyButtons = screen.getAllByRole("button", { name: /^Copy$/ }); + await user.click(copyButtons[1]); + expect(writeText).toHaveBeenCalled(); + }); + + it("loops back via a loop-back palette button after recursion is added", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: /^rec Recursion$/ })); + await user.click(screen.getByRole("button", { name: /Loop back to X0/ })); + expect(screen.getByText(/loop → X0/)).toBeInTheDocument(); + }); + + it("removes a step via its delete button", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click( + screen.getByRole("button", { name: /Send \/ Receive Pair/ }), + ); + await user.click(screen.getByRole("button", { name: "Delete pair" })); + expect( + screen.getByText( + "Click tools or constructs on the left to build your protocol", + ), + ).toBeInTheDocument(); + }); + + it("converts a paired send into an internal choice via the pair button", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: /search/ })); + await user.click( + screen.getByRole("button", { name: "Convert to internal choice" }), + ); + expect(screen.getAllByText("Internal Choice").length).toBeGreaterThan(1); + }); + + it("adds an internal choice and adds steps to a branch via insert target", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: /Internal Choice/ })); + await user.click( + screen.getAllByRole("button", { + name: "Target this branch for palette insertion", + })[0], + ); + // Insert-target banner shown + expect(screen.getByText(/Adding to:/)).toBeInTheDocument(); + // Adding a tool now goes into the targeted branch + await user.click(screen.getByRole("button", { name: /search/ })); + expect(screen.getAllByDisplayValue("search").length).toBeGreaterThan(0); + }); + + it("clears the insert target via the banner's clear button", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: /Internal Choice/ })); + await user.click( + screen.getAllByRole("button", { + name: "Target this branch for palette insertion", + })[0], + ); + await user.click( + screen.getByRole("button", { name: "Clear insert target" }), + ); + expect(screen.queryByText(/Adding to:/)).not.toBeInTheDocument(); + }); + + it("adds a recursion inside a targeted branch", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: /Internal Choice/ })); + await user.click( + screen.getAllByRole("button", { + name: "Target this branch for palette insertion", + })[0], + ); + await user.click(screen.getByRole("button", { name: /^rec Recursion$/ })); + // Loop-back palette button now appears for the new rec var + expect( + screen.getByRole("button", { name: /Loop back to X0/ }), + ).toBeInTheDocument(); + }); + + it("updates a step label by typing into its text input", async () => { + const user = userEvent.setup(); + // tools=[] forces TextInput rather than Select for the label editor + renderWithMantine(); + await user.click( + screen.getByRole("button", { name: /Send \/ Receive Pair/ }), + ); + const sendInput = screen.getByLabelText("Send label") as HTMLInputElement; + await user.clear(sendInput); + await user.type(sendInput, "Z"); + expect(sendInput.value).toBe("Z"); + }); + + it("triggers a download with the protocol contents", async () => { + const user = userEvent.setup(); + const createUrl = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:mock"); + const revokeUrl = vi.spyOn(URL, "revokeObjectURL").mockReturnValue(); + const click = vi.fn(); + vi.spyOn(document, "createElement").mockImplementation((tag) => { + if (tag === "a") { + const anchor = { + href: "", + download: "", + click, + } as unknown as HTMLAnchorElement; + return anchor; + } + return document.implementation.createHTMLDocument().createElement(tag); + }); + renderWithMantine(); + await user.click( + screen.getByRole("button", { name: /Download Python File/ }), + ); + expect(createUrl).toHaveBeenCalled(); + expect(click).toHaveBeenCalled(); + expect(revokeUrl).toHaveBeenCalledWith("blob:mock"); + }); +}); diff --git a/clients/web/src/components/screens/ProtocolBuilderScreen/ProtocolBuilderScreen.tsx b/clients/web/src/components/screens/ProtocolBuilderScreen/ProtocolBuilderScreen.tsx new file mode 100644 index 000000000..24e89f89d --- /dev/null +++ b/clients/web/src/components/screens/ProtocolBuilderScreen/ProtocolBuilderScreen.tsx @@ -0,0 +1,363 @@ +import { useCallback, useMemo, useState } from "react"; +import { Button, Card, Flex, Group, Paper, Stack, Text } from "@mantine/core"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { MdRestartAlt } from "react-icons/md"; +import { ProtocolPaletteSidebar } from "../../groups/ProtocolPaletteSidebar/ProtocolPaletteSidebar"; +import { ProtocolStepList } from "../../groups/ProtocolStepList/ProtocolStepList"; +import { ProtocolOutputPanel } from "../../groups/ProtocolOutputPanel/ProtocolOutputPanel"; +import { + addStepToBranch, + collectRecVars, + collectSendLabels, + convertPairToChoice, + deriveReceiveOptions, + findBranchLabel, + generatePythonSnippet, + isBranchTerminated, + isTerminated, + removeStepDeep, + stepsToProtocol, + uid, + updateStepDeep, + type Direction, + type InsertTarget, + type ProtocolStep, +} from "./protocol"; + +export interface ProtocolBuilderScreenProps { + tools: Tool[]; + listChanged: boolean; + onRefreshTools: () => void; +} + +const ScreenLayout = Flex.withProps({ + variant: "screen", + h: "calc(100vh - var(--app-shell-header-height, 0px))", + gap: "md", + p: "xl", + align: "flex-start", +}); + +const SidebarColumn = Stack.withProps({ + w: 320, + flex: "0 0 auto", +}); + +const SidebarCard = Card.withProps({ + withBorder: true, + padding: "lg", +}); + +const SequenceCard = Card.withProps({ + withBorder: true, + padding: "lg", +}); + +const OutputCard = Card.withProps({ + withBorder: true, + padding: "lg", +}); + +const SequenceHeader = Group.withProps({ + justify: "space-between", + align: "center", +}); + +const EmptyDrop = Paper.withProps({ + withBorder: true, + p: "xl", + ta: "center", + c: "dimmed", +}); + +const TerminalIndicator = Paper.withProps({ + withBorder: true, + px: "sm", + py: 6, +}); + +export function ProtocolBuilderScreen({ + tools, + listChanged, + onRefreshTools, +}: ProtocolBuilderScreenProps) { + const [steps, setSteps] = useState([]); + const [insertTarget, setInsertTarget] = useState(null); + const [copied, setCopied] = useState<"dsl" | "python" | null>(null); + + const protocol = useMemo(() => stepsToProtocol(steps), [steps]); + const pythonSnippet = useMemo( + () => generatePythonSnippet(protocol), + [protocol], + ); + const recVars = useMemo(() => collectRecVars(steps), [steps]); + const sendLabels = useMemo(() => collectSendLabels(steps), [steps]); + const receiveOptions = useMemo( + () => deriveReceiveOptions(sendLabels), + [sendLabels], + ); + const targetLabel = useMemo( + () => (insertTarget ? findBranchLabel(steps, insertTarget) : null), + [insertTarget, steps], + ); + const targetTerminated = useMemo( + () => + insertTarget + ? isBranchTerminated( + steps, + insertTarget.choiceStepId, + insertTarget.branchId, + ) + : isTerminated(steps), + [insertTarget, steps], + ); + + const addStepToTarget = useCallback( + (step: ProtocolStep) => { + if (insertTarget) { + setSteps((prev) => + addStepToBranch( + prev, + insertTarget.choiceStepId, + insertTarget.branchId, + step, + ), + ); + } else { + setSteps((prev) => [...prev, step]); + } + }, + [insertTarget], + ); + + const handleAddTool = useCallback( + (tool: Tool) => { + const pair = uid(); + const sendStep: ProtocolStep = { + id: uid(), + type: "action", + direction: "send", + label: tool.name, + toolName: tool.name, + pairId: pair, + }; + const recvStep: ProtocolStep = { + id: uid(), + type: "action", + direction: "receive", + label: `${tool.name}Result`, + toolName: tool.name, + pairId: pair, + }; + addStepToTarget(sendStep); + addStepToTarget(recvStep); + }, + [addStepToTarget], + ); + + const handleAddPair = useCallback(() => { + const pair = uid(); + addStepToTarget({ + id: uid(), + type: "action", + direction: "send", + label: "Action", + pairId: pair, + }); + addStepToTarget({ + id: uid(), + type: "action", + direction: "receive", + label: "ActionResult", + pairId: pair, + }); + }, [addStepToTarget]); + + const handleAddChoice = useCallback( + (direction: Direction) => { + addStepToTarget({ + id: uid(), + type: "choice", + direction, + branches: [ + { id: uid(), label: "BranchA", steps: [] }, + { id: uid(), label: "BranchB", steps: [] }, + ], + }); + }, + [addStepToTarget], + ); + + const handleAddRecursion = useCallback(() => { + setSteps((prev) => { + const varName = `X${collectRecVars(prev).length}`; + const recStep: ProtocolStep = { + id: uid(), + type: "recursion", + recVar: varName, + }; + if (insertTarget) { + return addStepToBranch( + prev, + insertTarget.choiceStepId, + insertTarget.branchId, + recStep, + ); + } + return [...prev, recStep]; + }); + }, [insertTarget]); + + const handleAddRecRef = useCallback( + (varName: string) => { + addStepToTarget({ + id: uid(), + type: "action", + isRecRef: true, + recVar: varName, + }); + }, + [addStepToTarget], + ); + + const handleUpdateStep = useCallback( + (stepId: string, updater: (s: ProtocolStep) => ProtocolStep) => { + setSteps((prev) => updateStepDeep(prev, stepId, updater)); + }, + [], + ); + + const handleRemoveStep = useCallback((stepId: string) => { + setSteps((prev) => removeStepDeep(prev, stepId)); + }, []); + + const handleConvertToChoice = useCallback( + ( + stepId: string, + pairId: string, + direction: Direction, + branchLabels: string[], + ) => { + setSteps((prev) => + convertPairToChoice(prev, stepId, pairId, direction, branchLabels), + ); + }, + [], + ); + + const handleClear = useCallback(() => { + setSteps([]); + setInsertTarget(null); + }, []); + + const flashCopied = useCallback((kind: "dsl" | "python") => { + setCopied(kind); + window.setTimeout(() => setCopied(null), 2000); + }, []); + + const handleCopyDsl = useCallback(() => { + navigator.clipboard?.writeText(protocol); + flashCopied("dsl"); + }, [protocol, flashCopied]); + + const handleCopyPython = useCallback(() => { + navigator.clipboard?.writeText(pythonSnippet); + flashCopied("python"); + }, [pythonSnippet, flashCopied]); + + const handleDownload = useCallback(() => { + const content = `# Protocol: ${protocol}\n\n${pythonSnippet}`; + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "protocol.py"; + a.click(); + URL.revokeObjectURL(url); + }, [protocol, pythonSnippet]); + + return ( + + + + setInsertTarget(null)} + onAddTool={handleAddTool} + onAddPair={handleAddPair} + onAddInternalChoice={() => handleAddChoice("send")} + onAddExternalChoice={() => handleAddChoice("receive")} + onAddRecursion={handleAddRecursion} + onAddRecRef={handleAddRecRef} + /> + + + + + + + + Protocol Sequence + + + + + {steps.length === 0 ? ( + + + Click tools or constructs on the left to build your protocol + + + ) : ( + + + + + end + + + + )} + + + + + + + Output + + + + + + ); +} diff --git a/clients/web/src/components/screens/ProtocolBuilderScreen/protocol.test.ts b/clients/web/src/components/screens/ProtocolBuilderScreen/protocol.test.ts new file mode 100644 index 000000000..bba2e197c --- /dev/null +++ b/clients/web/src/components/screens/ProtocolBuilderScreen/protocol.test.ts @@ -0,0 +1,512 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + addStepToBranch, + collectRecVars, + collectSendLabels, + convertPairToChoice, + deriveReceiveOptions, + findBranchLabel, + findPairId, + generatePythonSnippet, + isBranchTerminated, + isTerminated, + parseToFSM, + removeStepDeep, + resetUid, + stepsToProtocol, + uid, + updateStepDeep, + type ProtocolStep, +} from "./protocol"; + +beforeEach(() => { + resetUid(); +}); + +function send(label: string, opts: Partial = {}): ProtocolStep { + return { id: uid(), type: "action", direction: "send", label, ...opts }; +} +function recv(label: string, opts: Partial = {}): ProtocolStep { + return { id: uid(), type: "action", direction: "receive", label, ...opts }; +} + +describe("uid", () => { + it("returns sequential, unique ids", () => { + expect(uid()).toBe("step-1"); + expect(uid()).toBe("step-2"); + }); +}); + +describe("stepsToProtocol", () => { + it("returns 'end' for an empty step list", () => { + expect(stepsToProtocol([])).toBe("end"); + }); + + it("renders a simple send/receive sequence", () => { + const steps = [send("Search"), recv("SearchResult")]; + expect(stepsToProtocol(steps)).toBe("!Search.?SearchResult.end"); + }); + + it("renders a rec-ref instead of an action label", () => { + const steps: ProtocolStep[] = [ + { id: uid(), type: "action", isRecRef: true, recVar: "X" }, + ]; + expect(stepsToProtocol(steps)).toBe("X.end"); + }); + + it("renders an internal choice with branches", () => { + const choice: ProtocolStep = { + id: uid(), + type: "choice", + direction: "send", + branches: [ + { id: uid(), label: "Yes", steps: [send("Confirm")] }, + { id: uid(), label: "No", steps: [] }, + ], + }; + expect(stepsToProtocol([choice])).toBe("!{Yes.!Confirm.end, No}.end"); + }); + + it("renders an external choice", () => { + const choice: ProtocolStep = { + id: uid(), + type: "choice", + direction: "receive", + branches: [ + { id: uid(), label: "Ok", steps: [] }, + { id: uid(), label: "Err", steps: [] }, + ], + }; + expect(stepsToProtocol([choice])).toBe("?{Ok, Err}.end"); + }); + + it("renders a recursion scope wrapping the tail", () => { + const steps: ProtocolStep[] = [ + { id: uid(), type: "recursion", recVar: "X" }, + send("Ping"), + recv("Pong"), + { id: uid(), type: "action", isRecRef: true, recVar: "X" }, + ]; + expect(stepsToProtocol(steps)).toBe("rec X.!Ping.?Pong.X.end"); + }); + + it("renders content before a recursion as part of the prefix", () => { + const steps: ProtocolStep[] = [ + send("Init"), + { id: uid(), type: "recursion", recVar: "X" }, + send("Ping"), + ]; + expect(stepsToProtocol(steps)).toBe("!Init.rec X.!Ping.end"); + }); + + it("treats an empty receive label as a missing label string", () => { + const steps: ProtocolStep[] = [ + { id: uid(), type: "action", direction: "receive" }, + ]; + expect(stepsToProtocol(steps)).toBe("?.end"); + }); +}); + +describe("generatePythonSnippet", () => { + it("embeds the protocol DSL", () => { + const snippet = generatePythonSnippet("!A.?B.end"); + expect(snippet).toContain('protocol = "!A.?B.end"'); + expect(snippet).toContain("ToolMiddleware"); + }); +}); + +describe("updateStepDeep", () => { + it("updates a step at the top level", () => { + const a = send("A"); + const result = updateStepDeep([a], a.id, (s) => ({ ...s, label: "Z" })); + expect(result[0].label).toBe("Z"); + }); + + it("updates a step nested inside a branch", () => { + const inner = send("X"); + const choice: ProtocolStep = { + id: uid(), + type: "choice", + direction: "send", + branches: [{ id: uid(), label: "B", steps: [inner] }], + }; + const result = updateStepDeep([choice], inner.id, (s) => ({ + ...s, + label: "Y", + })); + expect(result[0].branches?.[0].steps[0].label).toBe("Y"); + }); + + it("returns the same step when not the target and no branches", () => { + const a = send("A"); + const result = updateStepDeep([a], "missing", (s) => s); + expect(result[0]).toBe(a); + }); +}); + +describe("findPairId / removeStepDeep", () => { + it("finds the pair id of a step", () => { + const pair = "p1"; + const a = send("A", { pairId: pair }); + const b = recv("AResult", { pairId: pair }); + expect(findPairId([a, b], a.id)).toBe(pair); + }); + + it("returns undefined for a missing step", () => { + expect(findPairId([send("A")], "missing")).toBeUndefined(); + }); + + it("finds a pair id nested in a branch", () => { + const inner = send("Inner", { pairId: "pX" }); + const choice: ProtocolStep = { + id: uid(), + type: "choice", + direction: "send", + branches: [{ id: uid(), label: "B", steps: [inner] }], + }; + expect(findPairId([choice], inner.id)).toBe("pX"); + }); + + it("removes both halves of a pair", () => { + const pair = "p1"; + const a = send("A", { pairId: pair }); + const b = recv("AResult", { pairId: pair }); + const c = send("C"); + const result = removeStepDeep([a, b, c], a.id); + expect(result).toEqual([c]); + }); + + it("removes a single unpaired step", () => { + const a = send("A"); + const b = send("B"); + const result = removeStepDeep([a, b], a.id); + expect(result.map((s) => s.label)).toEqual(["B"]); + }); + + it("recurses into branches when removing", () => { + const inner = send("X"); + const choice: ProtocolStep = { + id: uid(), + type: "choice", + direction: "send", + branches: [{ id: uid(), label: "B", steps: [inner] }], + }; + const result = removeStepDeep([choice], inner.id); + expect(result[0].branches?.[0].steps).toEqual([]); + }); +}); + +describe("addStepToBranch", () => { + it("appends a step to a branch by id", () => { + const choice: ProtocolStep = { + id: uid(), + type: "choice", + direction: "send", + branches: [{ id: "b1", label: "B", steps: [] }], + }; + const newStep = send("X"); + const result = addStepToBranch([choice], choice.id, "b1", newStep); + expect(result[0].branches?.[0].steps[0]).toBe(newStep); + }); + + it("recurses into nested branches", () => { + const innerChoice: ProtocolStep = { + id: "inner", + type: "choice", + direction: "send", + branches: [{ id: "ib", label: "IB", steps: [] }], + }; + const outer: ProtocolStep = { + id: "outer", + type: "choice", + direction: "send", + branches: [{ id: "ob", label: "OB", steps: [innerChoice] }], + }; + const newStep = send("X"); + const result = addStepToBranch([outer], "inner", "ib", newStep); + const ib = result[0].branches?.[0].steps[0].branches?.[0]; + expect(ib?.steps[0]).toBe(newStep); + }); + + it("leaves unrelated steps untouched", () => { + const a = send("A"); + expect(addStepToBranch([a], "x", "y", send("Z"))[0]).toBe(a); + }); +}); + +describe("isTerminated / isBranchTerminated", () => { + it("returns false for an empty list", () => { + expect(isTerminated([])).toBe(false); + }); + + it("returns true when the last step is a choice", () => { + const choice: ProtocolStep = { + id: uid(), + type: "choice", + direction: "send", + branches: [], + }; + expect(isTerminated([choice])).toBe(true); + }); + + it("returns true when the last step is a rec ref", () => { + const ref: ProtocolStep = { + id: uid(), + type: "action", + isRecRef: true, + recVar: "X", + }; + expect(isTerminated([ref])).toBe(true); + }); + + it("returns false when the last step is a plain action", () => { + expect(isTerminated([send("A")])).toBe(false); + }); + + it("returns false when the last step is a recursion scope opener", () => { + const recOpen: ProtocolStep = { + id: uid(), + type: "recursion", + recVar: "X", + }; + expect(isTerminated([recOpen])).toBe(false); + }); + + it("checks a branch by id", () => { + const choice: ProtocolStep = { + id: "c", + type: "choice", + direction: "send", + branches: [ + { + id: "b1", + label: "B", + steps: [{ id: uid(), type: "action", isRecRef: true, recVar: "X" }], + }, + { id: "b2", label: "B2", steps: [send("A")] }, + ], + }; + expect(isBranchTerminated([choice], "c", "b1")).toBe(true); + expect(isBranchTerminated([choice], "c", "b2")).toBe(false); + }); + + it("returns false when the choice or branch is missing", () => { + expect(isBranchTerminated([send("A")], "x", "y")).toBe(false); + }); + + it("recurses into nested branches", () => { + const inner: ProtocolStep = { + id: "inner", + type: "choice", + direction: "send", + branches: [{ id: "ib", label: "IB", steps: [send("Ok")] }], + }; + const outer: ProtocolStep = { + id: "outer", + type: "choice", + direction: "send", + branches: [{ id: "ob", label: "OB", steps: [inner] }], + }; + expect(isBranchTerminated([outer], "inner", "ib")).toBe(false); + }); +}); + +describe("collectRecVars / collectSendLabels / deriveReceiveOptions", () => { + it("collects recursion variables from nested branches", () => { + const innerRec: ProtocolStep = { + id: uid(), + type: "recursion", + recVar: "Y", + }; + const choice: ProtocolStep = { + id: uid(), + type: "choice", + direction: "send", + branches: [{ id: uid(), label: "B", steps: [innerRec] }], + }; + const outerRec: ProtocolStep = { + id: uid(), + type: "recursion", + recVar: "X", + }; + expect(collectRecVars([outerRec, choice])).toEqual(["X", "Y"]); + }); + + it("collects unique send labels excluding rec refs", () => { + const ref: ProtocolStep = { + id: uid(), + type: "action", + isRecRef: true, + recVar: "X", + }; + const steps = [send("A"), send("A"), recv("AResult"), ref]; + expect(collectSendLabels(steps)).toEqual(["A"]); + }); + + it("collects send labels nested in branches", () => { + const choice: ProtocolStep = { + id: uid(), + type: "choice", + direction: "send", + branches: [{ id: uid(), label: "B", steps: [send("Inner")] }], + }; + expect(collectSendLabels([choice])).toEqual(["Inner"]); + }); + + it("derives receive options from send labels", () => { + expect(deriveReceiveOptions(["Search"])).toEqual([ + "SearchResult", + "SearchError", + ]); + }); +}); + +describe("findBranchLabel", () => { + it("returns the matching branch label", () => { + const choice: ProtocolStep = { + id: "c", + type: "choice", + direction: "send", + branches: [{ id: "b", label: "Hit", steps: [] }], + }; + expect( + findBranchLabel([choice], { choiceStepId: "c", branchId: "b" }), + ).toBe("Hit"); + }); + + it("returns null when the target is missing", () => { + expect( + findBranchLabel([send("A")], { choiceStepId: "x", branchId: "y" }), + ).toBeNull(); + }); + + it("returns null when the choice exists but the branch does not", () => { + const choice: ProtocolStep = { + id: "c", + type: "choice", + direction: "send", + branches: [{ id: "b", label: "Hit", steps: [] }], + }; + expect( + findBranchLabel([choice], { choiceStepId: "c", branchId: "missing" }), + ).toBeNull(); + }); + + it("recurses into nested branches", () => { + const inner: ProtocolStep = { + id: "inner", + type: "choice", + direction: "send", + branches: [{ id: "ib", label: "Deep", steps: [] }], + }; + const outer: ProtocolStep = { + id: "outer", + type: "choice", + direction: "send", + branches: [{ id: "ob", label: "Outer", steps: [inner] }], + }; + expect( + findBranchLabel([outer], { choiceStepId: "inner", branchId: "ib" }), + ).toBe("Deep"); + }); +}); + +describe("convertPairToChoice", () => { + it("converts the targeted step to a choice and unpairs its partner", () => { + const pair = "p1"; + const a = send("A", { pairId: pair }); + const b = recv("AResult", { pairId: pair }); + const result = convertPairToChoice([a, b], a.id, pair, "send", ["X", "Y"]); + expect(result[0].type).toBe("choice"); + expect(result[0].branches?.map((br) => br.label)).toEqual(["X", "Y"]); + expect(result[1].pairId).toBeUndefined(); + }); + + it("recurses into branches", () => { + const pair = "p1"; + const inner = send("A", { pairId: pair }); + const partner = recv("AResult", { pairId: pair }); + const choice: ProtocolStep = { + id: uid(), + type: "choice", + direction: "send", + branches: [{ id: uid(), label: "B", steps: [inner, partner] }], + }; + const result = convertPairToChoice([choice], inner.id, pair, "send", ["X"]); + expect(result[0].branches?.[0].steps[0].type).toBe("choice"); + }); + + it("leaves unrelated steps untouched", () => { + const c = send("C"); + expect(convertPairToChoice([c], "missing", "p", "send", ["A"])[0]).toBe(c); + }); +}); + +describe("parseToFSM", () => { + it("returns just the start state for an empty protocol", () => { + const fsm = parseToFSM(""); + expect(fsm.states.has(0)).toBe(true); + expect(fsm.endStates.has(0)).toBe(true); + }); + + it("treats a literal 'end' as a terminal state", () => { + const fsm = parseToFSM("end"); + expect(fsm.endStates.has(0)).toBe(true); + }); + + it("creates a transition for a single send", () => { + const fsm = parseToFSM("!A.end"); + expect(fsm.transitions).toEqual([ + { from: 0, to: 1, dir: "send", label: "A" }, + ]); + expect(fsm.endStates.has(1)).toBe(true); + }); + + it("creates a transition for a single receive", () => { + const fsm = parseToFSM("?B.end"); + expect(fsm.transitions[0].dir).toBe("receive"); + }); + + it("emits one outgoing edge per branch in a choice", () => { + const fsm = parseToFSM("!{Yes.!Done.end, No.end}"); + const fromStart = fsm.transitions.filter((t) => t.from === 0); + expect(fromStart.map((t) => t.label).sort()).toEqual(["No", "Yes"]); + }); + + it("emits a loop transition for a rec reference", () => { + const fsm = parseToFSM("rec X.!Ping.?Pong.X"); + const loop = fsm.transitions.find((t) => t.dir === "loop"); + expect(loop?.label).toBe("X"); + expect(loop?.to).toBe(0); + }); + + it("ignores stray identifiers that aren't bound recursion vars", () => { + const fsm = parseToFSM("Bogus"); + expect(fsm.transitions).toEqual([]); + }); + + it("handles a choice with no labels gracefully", () => { + const fsm = parseToFSM("!{}"); + expect(fsm.transitions).toEqual([]); + }); + + it("handles a choice with only commas / whitespace", () => { + const fsm = parseToFSM("!{ , , }"); + expect(fsm.transitions).toEqual([]); + }); + + it("handles a send/receive prefix with no following label", () => { + const fsm = parseToFSM("!"); + expect(fsm.transitions).toEqual([]); + }); + + it("handles trailing dots without crashing", () => { + const fsm = parseToFSM("!A."); + expect(fsm.transitions[0]).toEqual({ + from: 0, + to: 1, + dir: "send", + label: "A", + }); + }); +}); diff --git a/clients/web/src/components/screens/ProtocolBuilderScreen/protocol.ts b/clients/web/src/components/screens/ProtocolBuilderScreen/protocol.ts new file mode 100644 index 000000000..263834c6e --- /dev/null +++ b/clients/web/src/components/screens/ProtocolBuilderScreen/protocol.ts @@ -0,0 +1,463 @@ +// Pure helpers for the Protocol Builder screen. No React, no DOM — just types +// and functions that operate on the in-memory protocol tree, render it to the +// session-type DSL, and project a finite-state-machine view of that DSL. + +export type Direction = "send" | "receive"; + +export interface ProtocolStep { + id: string; + type: "action" | "choice" | "recursion"; + direction?: Direction; + label?: string; + toolName?: string; + branches?: ProtocolBranch[]; + recVar?: string; + isRecRef?: boolean; + pairId?: string; +} + +export interface ProtocolBranch { + id: string; + label: string; + steps: ProtocolStep[]; +} + +export interface InsertTarget { + choiceStepId: string; + branchId: string; +} + +export interface FSMTransition { + from: number; + to: number; + dir: "send" | "receive" | "loop"; + label: string; +} + +export interface FSMResult { + states: Set; + transitions: FSMTransition[]; + endStates: Set; +} + +let nextId = 0; +export function uid(): string { + nextId += 1; + return `step-${nextId}`; +} + +// Reset the uid counter — only used in tests to keep IDs deterministic. +export function resetUid(): void { + nextId = 0; +} + +export function stepsToProtocol(steps: ProtocolStep[]): string { + if (steps.length === 0) return "end"; + const parts: string[] = []; + for (let i = 0; i < steps.length; i += 1) { + const step = steps[i]; + if (step.type === "action") { + if (step.isRecRef && step.recVar) { + parts.push(step.recVar); + } else { + const prefix = step.direction === "send" ? "!" : "?"; + parts.push(`${prefix}${step.label ?? ""}`); + } + } else if (step.type === "choice" && step.branches) { + const prefix = step.direction === "send" ? "!" : "?"; + const branchStrs = step.branches.map((b) => { + const inner = stepsToProtocol(b.steps); + return inner === "end" ? b.label : `${b.label}.${inner}`; + }); + parts.push(`${prefix}{${branchStrs.join(", ")}}`); + } else if (step.type === "recursion" && step.recVar) { + // `rec X.` opens a scope that wraps everything that follows at this level. + const tail = stepsToProtocol(steps.slice(i + 1)); + return `${parts.length > 0 ? `${parts.join(".")}.` : ""}rec ${step.recVar}.${tail}`; + } + } + if (parts.length === 0) return "end"; + return `${parts.join(".")}.end`; +} + +export function generatePythonSnippet(protocol: string): string { + return `from llmsessioncontract import Monitor, MonitoredClient, ToolMiddleware, LLMResponse + +# Define the protocol +protocol = "${protocol}" + +# Create a shared monitor +monitor = Monitor(protocol) + +# Wrap your LLM client +client = MonitoredClient( + llm_call=your_llm_fn, + response_adapter=your_adapter, + monitor=monitor, + send_label="Request", + receive_label=lambda r: "ToolCall" if r.has_tool_calls else "FinalAnswer", +) + +# Register tools with the middleware +tools = ToolMiddleware( + monitor=monitor, + tools={ + # "tool_name": tool_fn, + }, +)`; +} + +export function updateStepDeep( + steps: ProtocolStep[], + stepId: string, + updater: (step: ProtocolStep) => ProtocolStep, +): ProtocolStep[] { + return steps.map((s) => { + if (s.id === stepId) return updater(s); + if (s.branches) { + return { + ...s, + branches: s.branches.map((b) => ({ + ...b, + steps: updateStepDeep(b.steps, stepId, updater), + })), + }; + } + return s; + }); +} + +export function findPairId( + steps: ProtocolStep[], + stepId: string, +): string | undefined { + for (const s of steps) { + if (s.id === stepId) return s.pairId; + if (s.branches) { + for (const b of s.branches) { + const found = findPairId(b.steps, stepId); + if (found) return found; + } + } + } + return undefined; +} + +export function removeStepDeep( + steps: ProtocolStep[], + stepId: string, +): ProtocolStep[] { + // Removing one half of an `!?` pair drops both — keeps the UI invariant + // that paired steps always appear together (or not at all). + const pairId = findPairId(steps, stepId); + const shouldRemove = (s: ProtocolStep): boolean => + s.id === stepId || (pairId !== undefined && s.pairId === pairId); + + const filtered = steps.filter((s) => !shouldRemove(s)); + return filtered.map((s) => { + if (s.branches) { + return { + ...s, + branches: s.branches.map((b) => ({ + ...b, + steps: removeStepDeep(b.steps, stepId), + })), + }; + } + return s; + }); +} + +export function addStepToBranch( + steps: ProtocolStep[], + choiceStepId: string, + branchId: string, + newStep: ProtocolStep, +): ProtocolStep[] { + return steps.map((s) => { + if (s.id === choiceStepId && s.branches) { + return { + ...s, + branches: s.branches.map((b) => + b.id === branchId ? { ...b, steps: [...b.steps, newStep] } : b, + ), + }; + } + if (s.branches) { + return { + ...s, + branches: s.branches.map((b) => ({ + ...b, + steps: addStepToBranch(b.steps, choiceStepId, branchId, newStep), + })), + }; + } + return s; + }); +} + +export function isTerminated(steps: ProtocolStep[]): boolean { + if (steps.length === 0) return false; + const last = steps[steps.length - 1]; + if (last.type === "choice") return true; + // `rec X.` opens a scope; only a *reference* (loop back to X) terminates. + if (last.type === "action" && last.isRecRef) return true; + return false; +} + +export function collectRecVars(steps: ProtocolStep[]): string[] { + const vars: string[] = []; + for (const s of steps) { + if (s.type === "recursion" && s.recVar) vars.push(s.recVar); + if (s.branches) { + for (const b of s.branches) { + vars.push(...collectRecVars(b.steps)); + } + } + } + return vars; +} + +export function collectSendLabels(steps: ProtocolStep[]): string[] { + const labels: string[] = []; + for (const s of steps) { + if ( + s.type === "action" && + s.direction === "send" && + s.label && + !s.isRecRef + ) { + labels.push(s.label); + } + if (s.branches) { + for (const b of s.branches) { + labels.push(...collectSendLabels(b.steps)); + } + } + } + return [...new Set(labels)]; +} + +export function deriveReceiveOptions(sendLabels: string[]): string[] { + const options: string[] = []; + for (const label of sendLabels) { + options.push(`${label}Result`); + options.push(`${label}Error`); + } + return options; +} + +export function isBranchTerminated( + steps: ProtocolStep[], + choiceStepId: string, + branchId: string, +): boolean { + for (const s of steps) { + if (s.id === choiceStepId && s.branches) { + const branch = s.branches.find((b) => b.id === branchId); + return branch ? isTerminated(branch.steps) : false; + } + if (s.branches) { + for (const b of s.branches) { + if (isBranchTerminated(b.steps, choiceStepId, branchId)) return true; + } + } + } + return false; +} + +export function findBranchLabel( + steps: ProtocolStep[], + target: InsertTarget, +): string | null { + for (const s of steps) { + if (s.id === target.choiceStepId && s.branches) { + const b = s.branches.find((br) => br.id === target.branchId); + return b ? b.label : null; + } + if (s.branches) { + for (const b of s.branches) { + const found = findBranchLabel(b.steps, target); + if (found) return found; + } + } + } + return null; +} + +// Convert a paired action into a choice. The other half of the pair loses its +// pairId and stays in place as an unpaired action — it's no longer part of a +// pair, but the user's existing label survives. +export function convertPairToChoice( + steps: ProtocolStep[], + stepId: string, + pairId: string, + direction: Direction, + branchLabels: string[], +): ProtocolStep[] { + return steps.map((s) => { + if (s.id === stepId) { + return { + id: s.id, + type: "choice" as const, + direction, + branches: branchLabels.map((label) => ({ + id: uid(), + label, + steps: [], + })), + }; + } + if (s.pairId === pairId && s.id !== stepId) { + const next: ProtocolStep = { ...s }; + delete next.pairId; + return next; + } + if (s.branches) { + return { + ...s, + branches: s.branches.map((b) => ({ + ...b, + steps: convertPairToChoice( + b.steps, + stepId, + pairId, + direction, + branchLabels, + ), + })), + }; + } + return s; + }); +} + +// Parse the DSL string back into an FSM. Used for the state-machine preview +// in the output panel — purely for visualization, not for runtime monitoring. +export function parseToFSM(protocol: string): FSMResult { + const states = new Set([0]); + const transitions: FSMTransition[] = []; + const endStates = new Set(); + let nextState = 1; + let pos = 0; + const src = protocol; + const recVarStates = new Map(); + + const skipWS = (): void => { + while (pos < src.length && " \t\n\r".includes(src[pos])) pos += 1; + }; + + const readIdent = (): string => { + skipWS(); + const start = pos; + while (pos < src.length && /[a-zA-Z0-9_-]/.test(src[pos])) pos += 1; + return src.slice(start, pos); + }; + + const isWordEnd = (i: number): boolean => + i >= src.length || !/[a-zA-Z0-9_-]/.test(src[i]); + + const parse = (currentState: number): number => { + skipWS(); + if (pos >= src.length) { + endStates.add(currentState); + return currentState; + } + + if (src.slice(pos, pos + 3) === "end" && isWordEnd(pos + 3)) { + pos += 3; + endStates.add(currentState); + return currentState; + } + + if (src.slice(pos, pos + 3) === "rec" && isWordEnd(pos + 3)) { + pos += 3; + const varName = readIdent(); + recVarStates.set(varName, currentState); + skipWS(); + if (src[pos] === ".") pos += 1; + return parse(currentState); + } + + if (src[pos] === "!" || src[pos] === "?") { + const dir: "send" | "receive" = src[pos] === "!" ? "send" : "receive"; + pos += 1; + skipWS(); + + if (pos < src.length && src[pos] === "{") { + pos += 1; + const branchEndStates: number[] = []; + while (pos < src.length && src[pos] !== "}") { + skipWS(); + if (src[pos] === ",") { + pos += 1; + continue; + } + if (src[pos] === "}") break; + const label = readIdent(); + if (!label) { + pos += 1; + continue; + } + const branchTarget = nextState; + nextState += 1; + states.add(branchTarget); + transitions.push({ + from: currentState, + to: branchTarget, + dir, + label, + }); + skipWS(); + if (src[pos] === ".") { + pos += 1; + branchEndStates.push(parse(branchTarget)); + } else { + branchEndStates.push(branchTarget); + } + skipWS(); + if (src[pos] === ",") pos += 1; + } + if (src[pos] === "}") pos += 1; + return branchEndStates.length > 0 ? branchEndStates[0] : currentState; + } + + const label = readIdent(); + if (label) { + const targetState = nextState; + nextState += 1; + states.add(targetState); + transitions.push({ from: currentState, to: targetState, dir, label }); + skipWS(); + if (src[pos] === ".") { + pos += 1; + return parse(targetState); + } + endStates.add(targetState); + return targetState; + } + } else { + const ident = readIdent(); + const loopTarget = ident ? recVarStates.get(ident) : undefined; + if (loopTarget !== undefined) { + transitions.push({ + from: currentState, + to: loopTarget, + dir: "loop", + label: ident, + }); + return currentState; + } + } + + skipWS(); + if (src[pos] === ".") { + pos += 1; + return parse(currentState); + } + return currentState; + }; + + parse(0); + return { states, transitions, endStates }; +} diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 1ccdd5f2d..b3a9269f1 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -25,6 +25,7 @@ import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; import { TasksScreen } from "../../screens/TasksScreen/TasksScreen"; import type { TaskProgress } from "../../groups/TaskCard/TaskCard"; import { HistoryScreen } from "../../screens/HistoryScreen/HistoryScreen"; +import { ProtocolBuilderScreen } from "../../screens/ProtocolBuilderScreen/ProtocolBuilderScreen"; const SERVERS_TAB = "Servers"; @@ -36,6 +37,7 @@ const ALL_TABS: string[] = [ "Tasks", "Logs", "History", + "Protocol Builder", ]; const SCREEN_ENTER_MS = 350; @@ -341,6 +343,13 @@ export function InspectorView({ onTogglePin={noop} /> + + + diff --git a/clients/web/src/theme/Paper.ts b/clients/web/src/theme/Paper.ts index 8170703e2..8e775d143 100644 --- a/clients/web/src/theme/Paper.ts +++ b/clients/web/src/theme/Paper.ts @@ -14,6 +14,8 @@ export const ThemePaper = Paper.extend({ fontFamily: "var(--mantine-font-family-monospace)", fontSize: "var(--mantine-font-size-sm)", overflow: "auto", + whiteSpace: "pre-wrap", + wordBreak: "break-word", }, }; }