Add options to hide panes (#244)

* Add multiple pane views

* Fix rendering issues

* Fix copilot apply selection pane error and remove old debug logs

* Make top bar more compact

* Fix copilot generation from starting prompt

* Fix panel resizing issues

* Fix content preservation upon hiding panes

* Added changes on top of pane_layouts branch to remove example agent from the workflow editor

* Grey out options for changing pane layout during zero agents state

* Add zero-agent state for playground and publish buttons

* Fix pane sizes and bugs

---------

Co-authored-by: tusharmagar <tushmag@gmail.com>
This commit is contained in:
Akhilesh Sudhakar 2025-09-12 00:37:44 +04:00 committed by GitHub
parent 431f835ba1
commit ee02d61996
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 900 additions and 400 deletions

View file

@ -48,6 +48,8 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
isInitialState = false,
dataSources,
}, ref) {
const [messages, setMessages] = useState<z.infer<typeof CopilotMessage>[]>([]);
const [discardContext, setDiscardContext] = useState(false);
const [isLastInteracted, setIsLastInteracted] = useState(isInitialState);
@ -95,17 +97,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
onMessagesChange?.(messages);
}, [messages, onMessagesChange]);
// Check for initial prompt in local storage and send it
useEffect(() => {
const prompt = localStorage.getItem(`project_prompt_${projectId}`);
if (prompt && messages.length === 0) {
localStorage.removeItem(`project_prompt_${projectId}`);
setMessages([{
role: 'user',
content: prompt
}]);
}
}, [projectId, messages.length]);
// Removed localStorage auto-start. Initial prompts are sent by parent via ref.
// Reset discardContext when chatContext changes
useEffect(() => {
@ -134,15 +126,19 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
const currentStart = startRef.current;
const currentCancel = cancelRef.current;
currentStart(messages, (finalResponse: string) => {
setMessages(prev => [
...prev,
{
role: 'assistant',
content: finalResponse
}
]);
});
if (currentStart) {
currentStart(messages, (finalResponse: string) => {
setMessages(prev => [
...prev,
{
role: 'assistant',
content: finalResponse
}
]);
});
} else {
// startRef not yet ready; no-op
}
return () => currentCancel();
}, [messages, responseError]);
@ -269,7 +265,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
toolQuery={toolQuery}
/>
</div>
<div className="shrink-0 px-0 pb-0">
<div className="shrink-0 px-0 pb-10">
{responseError && (
<div className="mb-4 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex gap-2 justify-between items-center text-sm">
<p className="text-red-600 dark:text-red-400">{responseError}</p>
@ -322,8 +318,8 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
dispatch: (action: WorkflowDispatch) => void;
isInitialState?: boolean;
dataSources?: z.infer<typeof DataSource>[];
activePanel: 'playground' | 'copilot';
onTogglePanel: () => void;
activePanel?: 'playground' | 'copilot';
onTogglePanel?: () => void;
}>(({
projectId,
workflow,
@ -334,6 +330,13 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
activePanel,
onTogglePanel,
}, ref) => {
console.log('🎪 Copilot wrapper component mounted:', {
projectId,
isInitialState,
activePanel,
chatContextType: chatContext?.type
});
const [copilotKey, setCopilotKey] = useState(0);
const [showCopySuccess, setShowCopySuccess] = useState(false);
const [messages, setMessages] = useState<z.infer<typeof CopilotMessage>[]>([]);
@ -369,34 +372,7 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
<Panel
variant="copilot"
tourTarget="copilot"
title={
<div className="flex items-center">
<div className="flex items-center gap-2 rounded-lg p-1 bg-blue-50/70 dark:bg-blue-900/30">
<button
onClick={onTogglePanel}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 ${
activePanel === 'copilot'
? 'bg-white dark:bg-zinc-700 text-indigo-700 dark:text-indigo-300 shadow-md border border-indigo-200 dark:border-indigo-700'
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-zinc-100/60 dark:hover:bg-zinc-800/60'
}`}
>
<span className="text-base"></span>
Build
</button>
<button
onClick={onTogglePanel}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 ${
activePanel === 'playground'
? 'bg-white dark:bg-zinc-700 text-indigo-700 dark:text-indigo-300 shadow-md border border-indigo-200 dark:border-indigo-700'
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-zinc-100/60 dark:hover:bg-zinc-800/60'
}`}
>
<span className="text-base">💬</span>
Chat
</button>
</div>
</div>
}
title={<div className="flex items-center gap-2 text-zinc-800 dark:text-zinc-200 font-semibold"><Sparkles className="w-4 h-4" /> Skipper</div>}
subtitle="Build your assistant"
rightActions={
<div className="flex items-center gap-2">

View file

@ -67,14 +67,14 @@ export function Action({
switch (action.config_type) {
case 'agent':
dispatch({
type: 'update_agent',
type: 'update_agent_no_select',
name: action.name,
agent: changes
});
break;
case 'tool':
dispatch({
type: 'update_tool',
type: 'update_tool_no_select',
name: action.name,
tool: changes
});

View file

@ -222,7 +222,8 @@ function AssistantMessage({
agent: {
name: action.name,
...action.config_changes
}
},
fromCopilot: true
});
break;
}
@ -236,7 +237,8 @@ function AssistantMessage({
tool: {
name: action.name,
...action.config_changes
}
},
fromCopilot: true
});
break;
}
@ -246,7 +248,8 @@ function AssistantMessage({
prompt: {
name: action.name,
...action.config_changes
}
},
fromCopilot: true
});
break;
case 'pipeline':
@ -255,7 +258,8 @@ function AssistantMessage({
pipeline: {
name: action.name,
...action.config_changes
}
},
fromCopilot: true
});
break;
}
@ -263,14 +267,14 @@ function AssistantMessage({
switch (action.config_type) {
case 'agent':
dispatch({
type: 'update_agent',
type: 'update_agent_no_select',
name: action.name,
agent: action.config_changes
});
break;
case 'tool':
dispatch({
type: 'update_tool',
type: 'update_tool_no_select',
name: action.name,
tool: action.config_changes
});

View file

@ -38,6 +38,7 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
const [billingError, setBillingError] = useState<string | null>(null);
const cancelRef = useRef<() => void>(() => { });
const responseRef = useRef('');
const inFlightRef = useRef(false);
function clearError() {
setError(null);
@ -51,7 +52,19 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
messages: z.infer<typeof CopilotMessage>[],
onDone: (finalResponse: string) => void,
) => {
if (!messages.length || messages.at(-1)?.role !== 'user') return;
if (!messages.length || messages.at(-1)?.role !== 'user') {
return;
}
// Prevent duplicate/concurrent starts (e.g., StrictMode double effects or remounts)
if (inFlightRef.current) {
return;
}
inFlightRef.current = true;
setStreamingResponse('');
responseRef.current = '';
@ -61,16 +74,23 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
setLoading(true);
try {
// Wait 2 rAF frames to let layout stabilize (avoids StrictMode/remount race on initial load)
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
const res = await getCopilotResponseStream(projectId, messages, workflow, context || null, dataSources);
// Check for billing error
if ('billingError' in res) {
setLoading(false);
setError(res.billingError);
setBillingError(res.billingError);
inFlightRef.current = false;
return;
}
const eventSource = new EventSource(`/api/copilot-stream-response/${res.streamId}`);
eventSource.onmessage = (event) => {
@ -102,24 +122,29 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
eventSource.close();
setLoading(false);
onDone(responseRef.current);
inFlightRef.current = false;
});
eventSource.onerror = () => {
eventSource.close();
setError('Streaming failed');
setLoading(false);
inFlightRef.current = false;
};
cancelRef.current = () => eventSource.close();
} catch (err) {
console.error('❌ Error in useCopilot.start:', err);
setError('Failed to initiate stream');
setLoading(false);
inFlightRef.current = false;
}
}, [projectId, workflow, context, dataSources]);
const cancel = useCallback(() => {
cancelRef.current?.();
setLoading(false);
inFlightRef.current = false;
}, []);
return {

View file

@ -28,8 +28,8 @@ export function App({
onPanelClick?: () => void;
triggerCopilotChat?: (message: string) => void;
isLiveWorkflow: boolean;
activePanel: 'playground' | 'copilot';
onTogglePanel: () => void;
activePanel?: 'playground' | 'copilot';
onTogglePanel?: () => void;
onMessageSent?: () => void;
}) {
const [counter, setCounter] = useState<number>(0);
@ -56,6 +56,8 @@ export function App({
}
}, []);
const hasAgents = (workflow?.agents?.length || 0) > 0;
return (
<>
<Panel
@ -63,35 +65,13 @@ export function App({
variant="playground"
tourTarget="playground"
title={
<div className="flex items-center">
<div className="flex items-center gap-2 rounded-lg p-1 bg-blue-50/70 dark:bg-blue-900/30">
<button
onClick={onTogglePanel}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 ${
activePanel === 'copilot'
? 'bg-white dark:bg-zinc-700 text-indigo-700 dark:text-indigo-300 shadow-md border border-indigo-200 dark:border-indigo-700'
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-zinc-100/60 dark:hover:bg-zinc-800/60'
}`}
>
<span className="text-base"></span>
Build
</button>
<button
onClick={onTogglePanel}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 ${
activePanel === 'playground'
? 'bg-white dark:bg-zinc-700 text-indigo-700 dark:text-indigo-300 shadow-md border border-indigo-200 dark:border-indigo-700'
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-zinc-100/60 dark:hover:bg-zinc-800/60'
}`}
>
<span className="text-base">💬</span>
Chat
</button>
</div>
<div className="flex items-center gap-2 text-zinc-800 dark:text-zinc-200 font-semibold">
<MessageCircle className="w-4 h-4" />
Chat
</div>
}
subtitle="Chat with your assistant"
rightActions={
subtitle={hasAgents ? "Chat with your assistant" : "Create an agent to start chatting"}
rightActions={hasAgents ? (
<div className="flex items-center gap-2">
<Button
variant="primary"
@ -131,21 +111,46 @@ export function App({
)}
</Button>
</div>
}
) : (
// Preserve header height when there are zero agents
<div className="h-8" />
)}
onClick={onPanelClick}
>
<div className="h-full overflow-auto px-4 py-4">
<Chat
key={`chat-${counter}`}
projectId={projectId}
workflow={workflow}
messageSubscriber={messageSubscriber}
onCopyClick={(fn) => { getCopyContentRef.current = fn; }}
showDebugMessages={showDebugMessages}
triggerCopilotChat={triggerCopilotChat}
isLiveWorkflow={isLiveWorkflow}
onMessageSent={onMessageSent}
/>
{hasAgents ? (
<Chat
key={`chat-${counter}`}
projectId={projectId}
workflow={workflow}
messageSubscriber={messageSubscriber}
onCopyClick={(fn) => { getCopyContentRef.current = fn; }}
showDebugMessages={showDebugMessages}
triggerCopilotChat={triggerCopilotChat}
isLiveWorkflow={isLiveWorkflow}
onMessageSent={onMessageSent}
/>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center max-w-md">
<div className="mx-auto mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300">
<MessageCircle className="w-6 h-6" />
</div>
<div className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Create an agent to start chatting</div>
<div className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">Skipper can build agents for you!</div>
<div className="mt-4 flex items-center justify-center gap-3">
<Button
variant="primary"
size="sm"
className="!bg-blue-700 hover:!bg-blue-800 !text-white dark:!bg-blue-600 dark:hover:!bg-blue-700 !border !border-blue-700 dark:!border-blue-600"
onClick={() => triggerCopilotChat?.("Help me create my first agent.")}
>
Ask Skipper
</Button>
</div>
</div>
</div>
)}
</div>
</Panel>
</>

View file

@ -1,6 +1,6 @@
"use client";
import React from "react";
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Input } from "@heroui/react";
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Input, ButtonGroup } from "@heroui/react";
import { Button as CustomButton } from "@/components/ui/button";
import { RadioIcon, RedoIcon, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, SettingsIcon, ChevronDownIcon, ZapIcon, Clock, Plug, MessageCircleIcon, ShareIcon } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
@ -18,6 +18,7 @@ interface TopBarProps {
canUndo: boolean;
canRedo: boolean;
activePanel: 'playground' | 'copilot';
viewMode: "two_agents_chat" | "two_agents_skipper" | "two_chat_skipper" | "three_all";
hasAgentInstructionChanges: boolean;
hasPlaygroundTested: boolean;
hasPublished: boolean;
@ -29,6 +30,8 @@ interface TopBarProps {
onChangeMode: (mode: 'draft' | 'live') => void;
onRevertToLive: () => void;
onTogglePanel: () => void;
onSetViewMode: (mode: "two_agents_chat" | "two_agents_skipper" | "two_chat_skipper" | "three_all") => void;
hasAgents?: boolean;
onUseAssistantClick: () => void;
onStartNewChatAndFocus: () => void;
onStartBuildTour?: () => void;
@ -52,6 +55,7 @@ export function TopBar({
canUndo,
canRedo,
activePanel,
viewMode,
hasAgentInstructionChanges,
hasPlaygroundTested,
hasPublished,
@ -63,6 +67,8 @@ export function TopBar({
onChangeMode,
onRevertToLive,
onTogglePanel,
onSetViewMode,
hasAgents = true,
onUseAssistantClick,
onStartNewChatAndFocus,
onStartBuildTour,
@ -121,19 +127,24 @@ export function TopBar({
/>
</div>
{/* Show divider and CTA only in live view */}
{/* Show divider and mode indicator */}
{isLive && <div className="h-4 w-px bg-gray-300 dark:bg-gray-600"></div>}
{isLive ? (
<Button
variant="solid"
size="md"
size="sm"
onPress={() => onChangeMode('draft')}
className="gap-2 px-4 bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 font-medium text-sm border border-gray-200 dark:border-gray-600 shadow-sm"
startContent={<PenLine size={16} />}
className="gap-2 px-3 h-8 bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 font-medium text-sm border border-gray-200 dark:border-gray-600 shadow-sm"
startContent={<PenLine size={14} />}
>
Switch to draft
</Button>
) : null}
) : (
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300 font-medium text-xs rounded-full">
<PenLine size={12} />
<span>Draft</span>
</div>
)}
</div>
{/* Progress Bar - Center */}
@ -163,31 +174,106 @@ export function TopBar({
</div>}
{!isLive && <>
{!isLive && <div className="flex items-center gap-0.5">
<CustomButton
variant="primary"
size="sm"
onClick={onUndo}
disabled={!canUndo}
className="bg-gray-50 text-gray-700 hover:bg-gray-100 disabled:bg-gray-25 disabled:text-gray-400"
className="min-w-8 h-8 px-2 bg-gray-50 text-gray-700 hover:bg-gray-100 disabled:bg-gray-25 disabled:text-gray-400"
showHoverContent={true}
hoverContent="Undo"
>
<UndoIcon className="w-4 h-4" />
<UndoIcon className="w-3.5 h-3.5" />
</CustomButton>
<CustomButton
variant="primary"
size="sm"
onClick={onRedo}
disabled={!canRedo}
className="bg-gray-50 text-gray-700 hover:bg-gray-100 disabled:bg-gray-25 disabled:text-gray-400"
className="min-w-8 h-8 px-2 bg-gray-50 text-gray-700 hover:bg-gray-100 disabled:bg-gray-25 disabled:text-gray-400"
showHoverContent={true}
hoverContent="Redo"
>
<RedoIcon className="w-4 h-4" />
<RedoIcon className="w-3.5 h-3.5" />
</CustomButton>
</>}
</div>}
{/* View controls (hidden in live mode) */}
{!isLive && (<div className="flex items-center gap-2 mr-2">
{(() => {
// Current visibility booleans
const showAgents = viewMode !== "two_chat_skipper";
const showChat = viewMode !== "two_agents_skipper";
const showSkipper = viewMode !== "two_agents_chat";
// Determine selected radio option
type RadioKey = 'show-all' | 'hide-agents' | 'hide-chat' | 'hide-skipper';
let selectedKey: RadioKey = 'show-all';
if (!(showAgents && showChat && showSkipper)) {
if (!showAgents) selectedKey = 'hide-agents';
else if (!showChat) selectedKey = 'hide-chat';
else if (!showSkipper) selectedKey = 'hide-skipper';
}
// Map radio selection to viewMode
const setByKey = (key: RadioKey) => {
switch (key) {
case 'show-all':
onSetViewMode('three_all');
break;
case 'hide-agents':
onSetViewMode('two_chat_skipper');
break;
case 'hide-chat':
onSetViewMode('two_agents_skipper');
break;
case 'hide-skipper':
onSetViewMode('two_agents_chat');
break;
}
};
// Disable rules
// When there are zero agents, allow only Show All and Hide Chat
const zeroAgents = !hasAgents;
const disableShowAll = false; // always allow switching to 3-pane view
const disableHideAgents = zeroAgents; // cannot hide agents if none exist
const disableHideChat = false; // allow hide chat even with zero agents (default)
const disableHideSkipper = zeroAgents; // keep skipper visible when no agents
return (
<Dropdown>
<DropdownTrigger>
<Button variant="light" size="sm" aria-label="Layout options" className="h-8 min-w-0 bg-transparent text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100/60 dark:hover:bg-zinc-800/50 border border-transparent gap-1 px-2">
{/* Unified icon: 3-pane visual */}
<svg width="20" height="14" viewBox="0 0 22 16" aria-hidden="true">
<rect x="1" y="1" width="20" height="14" rx="2" fill="none" stroke="currentColor" opacity=".55" />
<rect x="2.3" y="2.5" width="5.5" height="11" rx="1.2" fill="currentColor" opacity=".8" />
<rect x="8.5" y="2.5" width="6" height="11" rx="1.2" fill="currentColor" opacity=".5" />
<rect x="15.5" y="2.5" width="5.5" height="11" rx="1.2" fill="currentColor" opacity=".4" />
</svg>
<ChevronDownIcon size={14} />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Choose layout" selectionMode="single" selectedKeys={[selectedKey]} closeOnSelect={true} onSelectionChange={(keys) => {
const key = Array.from(keys as Set<string>)[0] as RadioKey;
const zeroAgents = !hasAgents;
// Allow only permitted options when zero agents
if (zeroAgents && key !== 'show-all' && key !== 'hide-chat') return;
if (key === 'hide-chat' && disableHideChat) return;
setByKey(key);
}}>
<DropdownItem key="show-all" isDisabled={disableShowAll} className={selectedKey==='show-all' ? 'bg-zinc-100 dark:bg-zinc-800' : ''} startContent={<input type="radio" readOnly checked={selectedKey==='show-all'} className="accent-zinc-600 dark:accent-zinc-300" />}>Show All</DropdownItem>
<DropdownItem key="hide-agents" isDisabled={disableHideAgents} className={selectedKey==='hide-agents' ? 'bg-zinc-100 dark:bg-zinc-800' : ''} startContent={<input type="radio" readOnly checked={selectedKey==='hide-agents'} className="accent-zinc-600 dark:accent-zinc-300" />}>Hide Agents</DropdownItem>
<DropdownItem key="hide-chat" isDisabled={disableHideChat} className={selectedKey==='hide-chat' ? 'bg-zinc-100 dark:bg-zinc-800' : ''} startContent={<input type="radio" readOnly checked={selectedKey==='hide-chat'} className="accent-zinc-600 dark:accent-zinc-300" />}>Hide Chat</DropdownItem>
<DropdownItem key="hide-skipper" isDisabled={disableHideSkipper} className={selectedKey==='hide-skipper' ? 'bg-zinc-100 dark:bg-zinc-800' : ''} startContent={<input type="radio" readOnly checked={selectedKey==='hide-skipper'} className="accent-zinc-600 dark:accent-zinc-300" />}>Hide Skipper</DropdownItem>
</DropdownMenu>
</Dropdown>
);
})()}
</div>)}
{/* Deploy CTA - always visible */}
<div className="flex items-center gap-3">
{isLive ? (
@ -196,13 +282,13 @@ export function TopBar({
<DropdownTrigger>
<Button
variant="solid"
size="md"
className="gap-2 px-4 bg-blue-50 hover:bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-400 font-semibold text-sm border border-blue-200 dark:border-blue-700 shadow-sm"
startContent={<Plug size={16} />}
size="sm"
className="gap-2 px-3 h-8 bg-blue-50 hover:bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-400 font-semibold text-sm border border-blue-200 dark:border-blue-700 shadow-sm"
startContent={<Plug size={14} />}
onPress={onUseAssistantClick}
>
Use Assistant
<ChevronDownIcon size={14} />
<ChevronDownIcon size={12} />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Assistant access options">
@ -239,7 +325,6 @@ export function TopBar({
</DropdownMenu>
</Dropdown>
{/* Live workflow label moved here */}
<div className="flex items-center gap-2 ml-2">
{publishing && <Spinner size="sm" />}
<div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1.5">
@ -270,7 +355,7 @@ export function TopBar({
<Tooltip content="Download Assistant JSON">
<button
onClick={onDownloadJSON}
className="p-1.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
className="p-1.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors cursor-pointer"
aria-label="Download JSON"
type="button"
>
@ -282,47 +367,79 @@ export function TopBar({
) : (
<>
<div className="flex">
<Button
variant="solid"
size="md"
onPress={onPublishWorkflow}
className="gap-2 px-4 bg-green-100 hover:bg-green-200 text-green-800 font-semibold text-sm rounded-r-none border border-green-300 shadow-sm"
startContent={<RocketIcon size={16} />}
data-tour-target="deploy"
>
Publish
</Button>
<Dropdown>
<DropdownTrigger>
<Button
variant="solid"
size="md"
className="min-w-0 px-2 bg-green-100 hover:bg-green-200 text-green-800 rounded-l-none border border-l-0 border-green-300 shadow-sm"
>
<ChevronDownIcon size={14} />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Deploy actions">
<DropdownItem
key="view-live"
startContent={<RadioIcon size={16} />}
onPress={() => onChangeMode('live')}
>
View live version
</DropdownItem>
<DropdownItem
key="reset-to-live"
startContent={<AlertTriangle size={16} />}
onPress={onRevertToLive}
className="text-red-600 dark:text-red-400"
>
Reset to live version
</DropdownItem>
</DropdownMenu>
</Dropdown>
{(!hasAgents) ? (
<Tooltip content="Create agents to publish your assistant">
<span className="inline-flex">
<Button
variant="solid"
size="sm"
onPress={onPublishWorkflow}
isDisabled
className={`gap-2 px-3 h-8 font-semibold text-sm rounded-r-none border shadow-sm bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed`}
startContent={<RocketIcon size={14} />}
data-tour-target="deploy"
>
Publish
</Button>
</span>
</Tooltip>
) : (
<Button
variant="solid"
size="sm"
onPress={onPublishWorkflow}
className={`gap-2 px-3 h-8 font-semibold text-sm rounded-r-none border shadow-sm bg-green-100 hover:bg-green-200 text-green-800 border-green-300`}
startContent={<RocketIcon size={14} />}
data-tour-target="deploy"
>
Publish
</Button>
)}
{hasAgents ? (
<Dropdown>
<DropdownTrigger>
<Button
variant="solid"
size="sm"
className={`min-w-0 px-2 h-8 rounded-l-none border border-l-0 shadow-sm bg-green-100 hover:bg-green-200 text-green-800 border-green-300`}
>
<ChevronDownIcon size={12} />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Deploy actions">
<DropdownItem
key="view-live"
startContent={<RadioIcon size={16} />}
onPress={() => onChangeMode('live')}
>
View live version
</DropdownItem>
<DropdownItem
key="reset-to-live"
startContent={<AlertTriangle size={16} />}
onPress={onRevertToLive}
className="text-red-600 dark:text-red-400"
>
Reset to live version
</DropdownItem>
</DropdownMenu>
</Dropdown>
) : (
<Tooltip content="Create agents to publish your assistant">
<span className="inline-flex">
<Button
variant="solid"
size="sm"
isDisabled
className={`min-w-0 px-2 h-8 rounded-l-none border border-l-0 shadow-sm bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed`}
>
<ChevronDownIcon size={12} />
</Button>
</span>
</Tooltip>
)}
</div>
{/* Moved draft/live labels and download button here */}
<div className="flex items-center gap-2 ml-2">
{publishing && <Spinner size="sm" />}
{isLive && <div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1.5">
@ -357,7 +474,7 @@ export function TopBar({
<Tooltip content="Download Assistant JSON">
<button
onClick={onDownloadJSON}
className="p-1.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
className="p-1.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors cursor-pointer"
aria-label="Download JSON"
type="button"
>

View file

@ -41,12 +41,50 @@ import { TopBar } from "./components/TopBar";
enablePatches();
// View mode specific panel ratios
// To maintain same absolute width for entityList across modes, we need to calculate
// the percentage relative to visible panels only
const VIEW_MODE_RATIOS = {
three_all: {
// Three panel layout with equal distribution between chat and copilot
entityList: 25, // Agents panel takes 25% of total width
chatApp: 37.5, // Chat panel takes 37.5% of total width
copilot: 37.5 // Copilot panel takes 37.5% of total width
},
two_agents_chat: {
// Two panel layout showing agents and chat
// entityList maintains same absolute width as three panel layout (25/62.5 = 40%)
entityList: 40, // Agents panel takes 40% of visible width
chatApp: 60, // Chat panel takes remaining 60% width
copilot: 0 // Copilot panel is hidden
},
two_agents_skipper: {
// Two panel layout showing agents and copilot
// entityList maintains same absolute width as three panel layout (25/62.5 = 40%)
entityList: 40, // Agents panel takes 40% of visible width
chatApp: 0, // Chat panel is hidden
copilot: 60 // Copilot panel takes remaining 60% width
},
two_chat_skipper: {
// Two panel layout showing chat and copilot with equal split
entityList: 0, // Agents panel is hidden
chatApp: 50, // Chat panel takes 50% width
copilot: 50 // Copilot panel takes 50% width
}
} as const;
// Legacy PANEL_RATIOS for backward compatibility
const PANEL_RATIOS = {
entityList: 25, // Left panel
chatApp: 40, // Middle panel
copilot: 35 // Right panel
} as const;
// Helper function to get panel ratios for current view mode
const getPanelRatios = (viewMode: "two_agents_chat" | "two_agents_skipper" | "two_chat_skipper" | "three_all") => {
return VIEW_MODE_RATIOS[viewMode];
};
interface StateItem {
workflow: z.infer<typeof Workflow>;
publishing: boolean;
@ -89,19 +127,20 @@ export type Action = {
} | {
type: "add_agent";
agent: Partial<z.infer<typeof WorkflowAgent>>;
fromCopilot?: boolean;
} | {
type: "add_tool";
tool: Partial<z.infer<typeof WorkflowTool>>;
fromCopilot?: boolean;
} | {
type: "add_prompt";
prompt: Partial<z.infer<typeof WorkflowPrompt>>;
} | {
type: "add_prompt_no_select";
prompt: Partial<z.infer<typeof WorkflowPrompt>>;
fromCopilot?: boolean;
} | {
type: "add_pipeline";
pipeline: Partial<z.infer<typeof WorkflowPipeline>>;
defaultModel?: string;
fromCopilot?: boolean;
} | {
type: "select_agent";
name: string;
@ -128,10 +167,18 @@ export type Action = {
type: "update_agent";
name: string;
agent: Partial<z.infer<typeof WorkflowAgent>>;
} | {
type: "update_agent_no_select";
name: string;
agent: Partial<z.infer<typeof WorkflowAgent>>;
} | {
type: "update_tool";
name: string;
tool: Partial<z.infer<typeof WorkflowTool>>;
} | {
type: "update_tool_no_select";
name: string;
tool: Partial<z.infer<typeof WorkflowTool>>;
} | {
type: "set_saving";
saving: boolean;
@ -363,6 +410,9 @@ function reducer(state: State, action: Action): State {
newAgentName = `New agent ${draft.workflow.agents.filter((agent) =>
agent.name.startsWith("New agent")).length + 1}`;
}
const finalAgentName = action.agent.name || newAgentName;
draft.workflow?.agents.push({
name: newAgentName,
type: "conversation",
@ -379,10 +429,19 @@ function reducer(state: State, action: Action): State {
maxCallsPerParentAgent: 3,
...action.agent
});
draft.selection = {
type: "agent",
name: action.agent.name || newAgentName
};
// If this is the first agent or there's no start agent, set it as start agent
if (!draft.workflow?.startAgent || draft.workflow.agents.length === 1) {
draft.workflow.startAgent = finalAgentName;
}
// Only set selection if not from Copilot
if (!action.fromCopilot) {
draft.selection = {
type: "agent",
name: action.agent.name || newAgentName
};
}
draft.pendingChanges = true;
draft.chatKey++;
break;
@ -404,10 +463,13 @@ function reducer(state: State, action: Action): State {
mockTool: false,
...action.tool
});
draft.selection = {
type: "tool",
name: action.tool.name || newToolName
};
// Only set selection if not from Copilot
if (!action.fromCopilot) {
draft.selection = {
type: "tool",
name: action.tool.name || newToolName
};
}
draft.pendingChanges = true;
draft.chatKey++;
break;
@ -424,27 +486,13 @@ function reducer(state: State, action: Action): State {
prompt: "",
...action.prompt
});
draft.selection = {
type: "prompt",
name: action.prompt.name || newPromptName
};
draft.pendingChanges = true;
draft.chatKey++;
break;
}
case "add_prompt_no_select": {
let newPromptName = "New Variable";
if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) {
newPromptName = `New Variable ${draft.workflow?.prompts.filter((prompt) =>
prompt.name.startsWith("New Variable")).length + 1}`;
// Only set selection if not from Copilot
if (!action.fromCopilot) {
draft.selection = {
type: "prompt",
name: action.prompt.name || newPromptName
};
}
draft.workflow?.prompts.push({
name: newPromptName,
type: "base_prompt",
prompt: "",
...action.prompt
});
// Don't set selection - this is the key difference
draft.pendingChanges = true;
draft.chatKey++;
break;
@ -516,8 +564,8 @@ function reducer(state: State, action: Action): State {
...action.pipeline
});
// 4. ✅ Select the first agent for configuration
if (pipelineAgents.length > 0) {
// 4. ✅ Select the first agent for configuration (only if not from Copilot)
if (pipelineAgents.length > 0 && !action.fromCopilot) {
draft.selection = {
type: "agent",
name: pipelineAgents[0]
@ -717,6 +765,18 @@ function reducer(state: State, action: Action): State {
draft.chatKey++;
break;
}
case "update_agent_no_select": {
// Same as update_agent but do not change selection
if (action.agent.instructions !== undefined) {
draft.agentInstructionsChanged = true;
}
draft.workflow.agents = draft.workflow.agents.map((agent) =>
agent.name === action.name ? { ...agent, ...action.agent } : agent
);
draft.pendingChanges = true;
draft.chatKey++;
break;
}
case "update_tool":
// update tool data
@ -759,6 +819,13 @@ function reducer(state: State, action: Action): State {
draft.pendingChanges = true;
draft.chatKey++;
break;
case "update_tool_no_select":
draft.workflow.tools = draft.workflow.tools.map((tool) =>
tool.name === action.name ? { ...tool, ...action.tool } : tool
);
draft.pendingChanges = true;
draft.chatKey++;
break;
case "update_prompt":
// update prompt data
@ -938,6 +1005,56 @@ export function WorkflowEditor({
}
});
// View mode state controls top-level layout visibility (not unmounting panes)
type ViewMode = "two_agents_chat" | "two_agents_skipper" | "two_chat_skipper" | "three_all";
const [viewMode, setViewMode] = useState<ViewMode>(() => {
if (typeof window === 'undefined') return "three_all";
const fromUrl = new URLSearchParams(window.location.search).get('view');
const valid: ViewMode[] = ["two_agents_chat", "two_agents_skipper", "two_chat_skipper", "three_all"];
if (fromUrl && (valid as string[]).includes(fromUrl)) {
localStorage.setItem('workflow_view_mode', fromUrl);
return fromUrl as ViewMode;
}
return (localStorage.getItem('workflow_view_mode') as ViewMode) || "three_all";
});
const updateViewMode = useCallback((mode: ViewMode) => {
setViewMode(mode);
// Clear selection when switching to hide agents mode to close configuration panels
if (mode === 'two_chat_skipper') {
// Clear any active selection to close configuration panels
// All unselect actions set selection to null, so we can use any of them
dispatch({ type: "unselect_agent" });
}
if (typeof window !== 'undefined') {
localStorage.setItem('workflow_view_mode', mode);
const url = new URL(window.location.href);
url.searchParams.set('view', mode);
window.history.replaceState({}, '', url.toString());
}
}, []);
// 1) Auto-layout: when no agents exist, prefer Agents + Skipper
const prevAgentCountRef = useRef<number>(state.present.workflow.agents.length);
useEffect(() => {
const count = state.present.workflow.agents.length;
// If live mode, another effect will pin Agents + Chat; skip here
if (!isLive) {
if (count === 0) {
// Only auto-pin to Agents+Skipper if user hasn't explicitly chosen 3-pane
if (viewMode !== 'two_agents_skipper' && viewMode !== 'three_all') {
updateViewMode('two_agents_skipper');
}
} else if (prevAgentCountRef.current === 0 && count > 0) {
// 2) As soon as first agent is created from zero, switch to default (three panes)
updateViewMode('three_all');
}
}
prevAgentCountRef.current = count;
}, [state.present.workflow.agents.length, isLive, updateViewMode, viewMode]);
const [chatMessages, setChatMessages] = useState<z.infer<typeof Message>[]>([]);
const updateChatMessages = useCallback((messages: z.infer<typeof Message>[]) => {
setChatMessages(messages);
@ -1068,10 +1185,15 @@ export function WorkflowEditor({
if (startNewChatRef.current) {
startNewChatRef.current();
}
// Switch to playground (chat) mode and collapse left panel
// Ensure chat is visible and collapse left panel
setActivePanel('playground');
setViewMode((prev: ViewMode) => prev);
updateViewMode(
viewMode === 'three_all' ? 'three_all' :
(viewMode === 'two_agents_skipper' ? 'two_agents_chat' : 'two_chat_skipper')
);
setIsLeftPanelCollapsed(true);
}, []);
}, [updateViewMode, viewMode]);
// Load agent order from localStorage on mount
// useEffect(() => {
@ -1097,33 +1219,53 @@ export function WorkflowEditor({
// Function to trigger copilot chat
const triggerCopilotChat = useCallback((message: string) => {
setActivePanel('copilot');
updateViewMode(
viewMode === 'three_all' ? 'three_all' :
(viewMode === 'two_agents_chat' ? 'two_agents_skipper' : 'two_chat_skipper')
);
// Small delay to ensure copilot is mounted
setTimeout(() => {
copilotRef.current?.handleUserMessage(message);
}, 100);
}, []);
}, [updateViewMode, viewMode]);
const handleOpenDataSourcesModal = useCallback(() => {
entityListRef.current?.openDataSourcesModal();
}, []);
console.log(`workflow editor chat key: ${state.present.chatKey}`);
// Auto-show copilot and increment key when prompt is present
// Auto-show copilot and send initial prompt exactly once when present
const hasSentInitPromptRef = useRef<boolean>(false);
useEffect(() => {
if (hasSentInitPromptRef.current) return;
const prompt = localStorage.getItem(`project_prompt_${projectId}`);
console.log('init project prompt', prompt);
if (prompt) {
setActivePanel('copilot');
}
}, [projectId]);
if (!prompt) return;
// Mark as handled and remove immediately to avoid any other readers
hasSentInitPromptRef.current = true;
localStorage.removeItem(`project_prompt_${projectId}`);
// Switch UI to show Copilot
setActivePanel('copilot');
updateViewMode(viewMode === 'three_all' ? 'three_all' : (viewMode.includes('agents') ? 'two_agents_skipper' : 'two_chat_skipper'));
// Allow layout to render Copilot, then send the prompt via ref
requestAnimationFrame(() => {
requestAnimationFrame(() => {
copilotRef.current?.handleUserMessage(prompt);
});
});
}, [projectId, updateViewMode, viewMode]);
// Switch to playground when switching to live mode
useEffect(() => {
if (isLive) {
setActivePanel('playground');
// 3) In live mode, pin view to Agents + Chat
updateViewMode('two_agents_chat');
}
}, [isLive]);
}, [isLive, updateViewMode, viewMode]);
// Reset initial state when user interacts with copilot or opens other menus
useEffect(() => {
@ -1307,7 +1449,7 @@ export function WorkflowEditor({
// Modal-specific handlers that don't auto-select
function handleAddPromptFromModal(prompt: Partial<z.infer<typeof WorkflowPrompt>>) {
dispatch({ type: "add_prompt_no_select", prompt });
dispatch({ type: "add_prompt", prompt, fromCopilot: true });
}
function handleUpdatePromptFromModal(name: string, prompt: Partial<z.infer<typeof WorkflowPrompt>>) {
@ -1521,7 +1663,7 @@ export function WorkflowEditor({
}, [isLive, state.present.publishing, onChangeMode]);
const WORKFLOW_MOD_ACTIONS = useRef(new Set([
'add_agent','add_tool','add_prompt','add_prompt_no_select','add_pipeline',
'add_agent','add_tool','add_prompt','add_pipeline',
'update_agent','update_tool','update_prompt','update_prompt_no_select','update_pipeline',
'delete_agent','delete_tool','delete_prompt','delete_pipeline',
'toggle_agent','set_main_agent','reorder_agents','reorder_pipelines'
@ -1595,14 +1737,21 @@ export function WorkflowEditor({
}, [isLive, state.present.isLive]);
function handleTogglePanel() {
if (isLive && activePanel === 'playground') {
if (isLive && (viewMode === 'two_agents_chat' || viewMode === 'two_chat_skipper' || viewMode === 'three_all')) {
// User is trying to switch to Build mode in live mode
handleModeTransition('draft', 'switch_draft');
setShowBuildModeBanner(true);
// Auto-hide banner after 5 seconds
setTimeout(() => setShowBuildModeBanner(false), 5000);
} else {
setActivePanel(activePanel === 'playground' ? 'copilot' : 'playground');
// Toggle between showing chat vs skipper within current context
if (viewMode === 'three_all') {
setActivePanel(activePanel === 'playground' ? 'copilot' : 'playground');
return;
}
if (viewMode === 'two_agents_chat') updateViewMode('two_agents_skipper');
else if (viewMode === 'two_agents_skipper') updateViewMode('two_agents_chat');
else if (viewMode === 'two_chat_skipper') updateViewMode('two_chat_skipper');
}
}
@ -1663,6 +1812,9 @@ export function WorkflowEditor({
}
};
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => { setIsHydrated(true); }, []);
return (
<EntitySelectionContext.Provider value={{
onSelectAgent: handleSelectAgent,
@ -1716,6 +1868,8 @@ export function WorkflowEditor({
canUndo={state.currentIndex > 0}
canRedo={state.currentIndex < state.patches.length}
activePanel={activePanel}
viewMode={viewMode}
hasAgents={state.present.workflow.agents.length > 0}
hasAgentInstructionChanges={hasAgentInstructionChanges}
hasPlaygroundTested={hasPlaygroundTested}
hasPublished={hasPublished}
@ -1730,26 +1884,138 @@ export function WorkflowEditor({
onChangeMode={onChangeMode}
onRevertToLive={handleRevertToLive}
onTogglePanel={handleTogglePanel}
onSetViewMode={updateViewMode}
onUseAssistantClick={markUseAssistantClicked}
onStartNewChatAndFocus={handleStartNewChatAndFocus}
onStartBuildTour={() => setShowBuildTour(true)}
onStartTestTour={() => setShowTestTour(true)}
onStartBuildTour={() => {
// Ensure 3-pane layout first, then start tour after layout renders
updateViewMode('three_all');
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setShowBuildTour(true);
});
});
}}
onStartTestTour={() => {
updateViewMode('three_all');
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setShowTestTour(true);
});
});
}}
onStartPublishTour={() => {
// Switch to 3-pane first to ensure elements are visible
updateViewMode('three_all');
if (isLive) {
handleModeTransition('draft', 'switch_draft');
}
setShowPublishTour(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setShowPublishTour(true);
});
});
}}
onStartUseTour={() => {
updateViewMode('three_all');
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setShowUseTour(true);
});
});
}}
onStartUseTour={() => setShowUseTour(true)}
/>
{/* Content Area */}
<ResizablePanelGroup direction="horizontal" className="flex-1 flex overflow-auto gap-1 rounded-xl bg-zinc-50 dark:bg-zinc-900">
{/* Content Area - hydration-safe layout */}
{!isHydrated ? (
<ResizablePanelGroup key={`hydration-${viewMode}`} direction="horizontal" className="flex-1 flex overflow-auto gap-1 rounded-xl bg-zinc-50 dark:bg-zinc-900">
{(viewMode !== 'two_chat_skipper') && (
<ResizablePanel
key={`entity-list-${state.present.selection ? '3-pane' : '2-pane'}`}
key={`entity-list-hydration`}
minSize={10}
defaultSize={PANEL_RATIOS.entityList}
className={isLeftPanelCollapsed ? 'hidden' : ''}
defaultSize={getPanelRatios(viewMode).entityList}
id="entities"
order={1}
className={`${isLeftPanelCollapsed ? 'hidden' : ''}`}
>
<div className="flex flex-col h-full">
<EntityList
ref={entityListRef}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
prompts={state.present.workflow.prompts}
pipelines={state.present.workflow.pipelines || []}
dataSources={dataSources}
workflow={state.present.workflow}
selectedEntity={null}
startAgentName={state.present.workflow.startAgent}
onSelectAgent={handleSelectAgent}
onSelectTool={handleSelectTool}
onSelectPrompt={handleSelectPrompt}
onSelectPipeline={handleSelectPipeline}
onSelectDataSource={handleSelectDataSource}
onAddAgent={handleAddAgent}
onAddTool={handleAddTool}
onAddPrompt={handleAddPrompt}
onUpdatePrompt={handleUpdatePrompt}
onAddPromptFromModal={handleAddPromptFromModal}
onUpdatePromptFromModal={handleUpdatePromptFromModal}
onAddPipeline={handleAddPipeline}
onAddAgentToPipeline={handleAddAgentToPipeline}
onToggleAgent={handleToggleAgent}
onSetMainAgent={handleSetMainAgent}
onDeleteAgent={handleDeleteAgent}
onDeleteTool={handleDeleteTool}
onDeletePrompt={handleDeletePrompt}
onDeletePipeline={handleDeletePipeline}
onShowVisualise={handleShowVisualise}
projectId={projectId}
onProjectToolsUpdated={onProjectToolsUpdated}
onDataSourcesUpdated={onDataSourcesUpdated}
projectConfig={projectConfig}
onReorderAgents={handleReorderAgents}
onReorderPipelines={handleReorderPipelines}
useRagUploads={useRagUploads}
useRagS3Uploads={useRagS3Uploads}
useRagScraping={useRagScraping}
/>
</div>
</ResizablePanel>
)}
{(viewMode !== 'two_chat_skipper') && (
<ResizableHandle withHandle className={`w-[3px] bg-transparent ${(isLeftPanelCollapsed) ? 'hidden' : ''}`} />
)}
{(viewMode === 'two_agents_chat' || viewMode === 'three_all') && (
<ResizablePanel minSize={20} defaultSize={getPanelRatios(viewMode).chatApp} id="chat" order={2} className="overflow-hidden">
{/* Minimal mount of Chat during SSR hydration */}
<div className="h-full" />
</ResizablePanel>
)}
{(viewMode === 'three_all') && (<ResizableHandle withHandle className="w-[3px] bg-transparent" />)}
{(viewMode === 'two_agents_skipper' || viewMode === 'three_all') && (
<ResizablePanel minSize={20} defaultSize={getPanelRatios(viewMode).copilot} id="copilot" order={3} className="overflow-hidden">
<div className="h-full" />
</ResizablePanel>
)}
{(viewMode === 'two_chat_skipper') && (
<>
<ResizablePanel minSize={20} defaultSize={getPanelRatios(viewMode).chatApp} id="chat" order={1} className="overflow-hidden"><div className="h-full" /></ResizablePanel>
<ResizableHandle withHandle className="w-[3px] bg-transparent" />
<ResizablePanel minSize={20} defaultSize={getPanelRatios(viewMode).copilot} id="copilot" order={2} className="overflow-hidden"><div className="h-full" /></ResizablePanel>
</>
)}
</ResizablePanelGroup>
) : (
<ResizablePanelGroup key={`main-${viewMode}`} direction="horizontal" className="flex-1 flex overflow-auto gap-1 rounded-xl bg-zinc-50 dark:bg-zinc-900">
{/* Agents (Entity List) column */}
{(viewMode !== 'two_chat_skipper') && (
<ResizablePanel
key={`entity-list-main`}
minSize={10}
defaultSize={getPanelRatios(viewMode).entityList}
id="entities"
order={1}
className={`${isLeftPanelCollapsed ? 'hidden' : ''}`}
>
<div className="flex flex-col h-full">
<EntityList
@ -1803,150 +2069,209 @@ export function WorkflowEditor({
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={`w-[3px] bg-transparent ${(isLeftPanelCollapsed || !state.present.selection) ? 'hidden' : ''}`} />
)}
{(viewMode !== 'two_chat_skipper') && (
<ResizableHandle withHandle className={`w-[3px] bg-transparent ${(isLeftPanelCollapsed && !state.present.selection) ? 'hidden' : ''}`} />
)}
{/* Config Panel - always rendered, visibility controlled */}
<ResizablePanel
minSize={20}
defaultSize={45}
className={`overflow-auto ${!state.present.selection ? 'hidden' : ''}`}
>
{state.present.selection?.type === "agent" && <AgentConfig
key={`agent-${state.present.workflow.agents.findIndex(agent => agent.name === state.present.selection!.name)}-${configKey}`}
{/* Playground column - always mounted; hide via viewMode */}
<ResizablePanel minSize={20} defaultSize={getPanelRatios(viewMode).chatApp} id="chat" order={2} className={`overflow-hidden relative ${viewMode === 'two_agents_skipper' ? 'hidden' : ''}`}>
<ChatApp
key={'' + state.present.chatKey}
projectId={projectId}
workflow={state.present.workflow}
agent={state.present.workflow.agents.find((agent) => agent.name === state.present.selection!.name)!}
usedAgentNames={new Set(state.present.workflow.agents.filter((agent) => agent.name !== state.present.selection!.name).map((agent) => agent.name))}
usedPipelineNames={new Set((state.present.workflow.pipelines || []).map((pipeline) => pipeline.name))}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
prompts={state.present.workflow.prompts}
dataSources={dataSources}
handleUpdate={(update) => { dispatchGuarded({ type: "update_agent", name: state.present.selection!.name, agent: update }); }}
handleClose={handleUnselectAgent}
useRag={useRag}
messageSubscriber={updateChatMessages}
onPanelClick={handlePlaygroundClick}
triggerCopilotChat={triggerCopilotChat}
eligibleModels={eligibleModels === "*" ? "*" : eligibleModels.agentModels}
onOpenDataSourcesModal={handleOpenDataSourcesModal}
/>}
{state.present.selection?.type === "tool" && (() => {
const selectedTool = state.present.workflow.tools.find(
(tool) => tool.name === state.present.selection!.name
);
return <ToolConfig
key={`${state.present.selection.name}-${configKey}`}
tool={selectedTool!}
usedToolNames={new Set([
...state.present.workflow.tools.filter((tool) => tool.name !== state.present.selection!.name).map((tool) => tool.name),
])}
handleUpdate={(update) => { dispatchGuarded({ type: "update_tool", name: state.present.selection!.name, tool: update }); }}
handleClose={handleUnselectTool}
/>;
})()}
{state.present.selection?.type === "prompt" && <PromptConfig
key={`${state.present.selection.name}-${configKey}`}
prompt={state.present.workflow.prompts.find((prompt) => prompt.name === state.present.selection!.name)!}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
prompts={state.present.workflow.prompts}
usedPromptNames={new Set(state.present.workflow.prompts.filter((prompt) => prompt.name !== state.present.selection!.name).map((prompt) => prompt.name))}
handleUpdate={(update) => { dispatchGuarded({ type: "update_prompt", name: state.present.selection!.name, prompt: update }); }}
handleClose={handleUnselectPrompt}
/>}
{state.present.selection?.type === "datasource" && <DataSourceConfig
key={`${state.present.selection.name}-${configKey}`}
dataSourceId={state.present.selection.name}
handleClose={() => dispatch({ type: "unselect_datasource" })}
onDataSourceUpdate={onDataSourcesUpdated}
/>}
{state.present.selection?.type === "pipeline" && <PipelineConfig
key={`${state.present.selection.name}-${configKey}`}
projectId={projectId}
workflow={state.present.workflow}
pipeline={state.present.workflow.pipelines?.find((pipeline) => pipeline.name === state.present.selection!.name)!}
usedPipelineNames={new Set((state.present.workflow.pipelines || []).filter((pipeline) => pipeline.name !== state.present.selection!.name).map((pipeline) => pipeline.name))}
usedAgentNames={new Set(state.present.workflow.agents.map((agent) => agent.name))}
agents={state.present.workflow.agents}
pipelines={state.present.workflow.pipelines || []}
handleUpdate={handleUpdatePipeline.bind(null, state.present.selection.name)}
handleClose={() => dispatch({ type: "unselect_pipeline" })}
/>}
{state.present.selection?.type === "visualise" && (
<Panel
title={
<div className="flex items-center justify-between w-full">
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">
Agent Graph Visualizer
</div>
<CustomButton
variant="secondary"
size="sm"
onClick={handleHideVisualise}
showHoverContent={true}
hoverContent="Close"
>
<XIcon className="w-4 h-4" />
</CustomButton>
</div>
}
>
<div className="h-full overflow-hidden">
<AgentGraphVisualizer workflow={state.present.workflow} />
isLiveWorkflow={isLive}
activePanel={activePanel}
onTogglePanel={handleTogglePanel}
onMessageSent={markPlaygroundTested}
/>
{/* Config overlay above Playground when selection open */}
{state.present.selection && viewMode !== 'two_agents_skipper' && (
<div className="absolute inset-0 z-10">
<div className="h-full overflow-auto">
{state.present.selection?.type === "agent" && <AgentConfig
key={`overlay-agent-${state.present.workflow.agents.findIndex(agent => agent.name === state.present.selection!.name)}-${configKey}`}
projectId={projectId}
workflow={state.present.workflow}
agent={state.present.workflow.agents.find((agent) => agent.name === state.present.selection!.name)!}
usedAgentNames={new Set(state.present.workflow.agents.filter((agent) => agent.name !== state.present.selection!.name).map((agent) => agent.name))}
usedPipelineNames={new Set((state.present.workflow.pipelines || []).map((pipeline) => pipeline.name))}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
prompts={state.present.workflow.prompts}
dataSources={dataSources}
handleUpdate={(update) => { dispatchGuarded({ type: "update_agent", name: state.present.selection!.name, agent: update }); }}
handleClose={handleUnselectAgent}
useRag={useRag}
triggerCopilotChat={triggerCopilotChat}
eligibleModels={eligibleModels === "*" ? "*" : eligibleModels.agentModels}
onOpenDataSourcesModal={handleOpenDataSourcesModal}
/>}
{state.present.selection?.type === "tool" && (() => {
const selectedTool = state.present.workflow.tools.find(
(tool) => tool.name === state.present.selection!.name
);
return <ToolConfig
key={`overlay-${state.present.selection.name}-${configKey}`}
tool={selectedTool!}
usedToolNames={new Set([
...state.present.workflow.tools.filter((tool) => tool.name !== state.present.selection!.name).map((tool) => tool.name),
])}
handleUpdate={(update) => { dispatchGuarded({ type: "update_tool", name: state.present.selection!.name, tool: update }); }}
handleClose={handleUnselectTool}
/>;
})()}
{state.present.selection?.type === "prompt" && <PromptConfig
key={`overlay-${state.present.selection.name}-${configKey}`}
prompt={state.present.workflow.prompts.find((prompt) => prompt.name === state.present.selection!.name)!}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
prompts={state.present.workflow.prompts}
usedPromptNames={new Set(state.present.workflow.prompts.filter((prompt) => prompt.name !== state.present.selection!.name).map((prompt) => prompt.name))}
handleUpdate={(update) => { dispatchGuarded({ type: "update_prompt", name: state.present.selection!.name, prompt: update }); }}
handleClose={handleUnselectPrompt}
/>}
{state.present.selection?.type === "datasource" && <DataSourceConfig
key={`overlay-${state.present.selection.name}-${configKey}`}
dataSourceId={state.present.selection.name}
handleClose={() => dispatch({ type: "unselect_datasource" })}
onDataSourceUpdate={onDataSourcesUpdated}
/>}
{state.present.selection?.type === "pipeline" && <PipelineConfig
key={`overlay-${state.present.selection.name}-${configKey}`}
projectId={projectId}
workflow={state.present.workflow}
pipeline={state.present.workflow.pipelines?.find((pipeline) => pipeline.name === state.present.selection!.name)!}
usedPipelineNames={new Set((state.present.workflow.pipelines || []).filter((pipeline) => pipeline.name !== state.present.selection!.name).map((pipeline) => pipeline.name))}
usedAgentNames={new Set(state.present.workflow.agents.map((agent) => agent.name))}
agents={state.present.workflow.agents}
pipelines={state.present.workflow.pipelines || []}
handleUpdate={handleUpdatePipeline.bind(null, state.present.selection.name)}
handleClose={() => dispatch({ type: "unselect_pipeline" })}
/>}
{state.present.selection?.type === "visualise" && (
<Panel title={<div className="flex items-center justify-between w-full"><div className="text-base font-semibold text-gray-900 dark:text-gray-100">Agent Graph Visualizer</div><CustomButton variant="secondary" size="sm" onClick={handleHideVisualise} showHoverContent={true} hoverContent="Close"><XIcon className="w-4 h-4" /></CustomButton></div>}>
<div className="h-full overflow-hidden">
<AgentGraphVisualizer workflow={state.present.workflow} />
</div>
</Panel>
)}
</div>
</Panel>
</div>
)}
</ResizablePanel>
{/* Second handle - between config and chat panels */}
<ResizableHandle withHandle className={`w-[3px] bg-transparent ${(isLeftPanelCollapsed && !state.present.selection) ? 'hidden' : ''}`} />
{/* ChatApp/Copilot Panel - always visible */}
<ResizablePanel
key={`chat-panel-${state.present.selection ? '3-pane' : '2-pane'}`}
minSize={20}
defaultSize={state.present.selection ? 30 : PANEL_RATIOS.chatApp + PANEL_RATIOS.copilot}
className="overflow-auto"
>
<div className={(activePanel === 'playground') ? 'block h-full' : 'hidden h-full'}>
<ChatApp
key={'' + state.present.chatKey}
projectId={projectId}
workflow={state.present.workflow}
messageSubscriber={updateChatMessages}
onPanelClick={handlePlaygroundClick}
triggerCopilotChat={triggerCopilotChat}
isLiveWorkflow={isLive}
activePanel={activePanel}
onTogglePanel={handleTogglePanel}
onMessageSent={markPlaygroundTested}
/>
</div>
<div className={(activePanel === 'copilot') ? 'block h-full' : 'hidden h-full'}>
<Copilot
ref={copilotRef}
projectId={projectId}
workflow={state.present.workflow}
dispatch={dispatch}
chatContext={
state.present.selection &&
(state.present.selection.type === "agent" ||
state.present.selection.type === "tool" ||
state.present.selection.type === "prompt")
? {
type: state.present.selection.type,
name: state.present.selection.name
}
: chatMessages.length > 0
? { type: 'chat', messages: chatMessages }
: undefined
}
isInitialState={isInitialState}
dataSources={dataSources}
activePanel={activePanel}
onTogglePanel={handleTogglePanel}
/>
</div>
{/* Divider between playground and copilot when both visible */}
{(viewMode === 'three_all' || viewMode === 'two_chat_skipper') && (
<ResizableHandle withHandle className="w-[3px] bg-transparent" />
)}
{/* Copilot column - always mounted; hide via viewMode */}
<ResizablePanel minSize={20} defaultSize={getPanelRatios(viewMode).copilot} id="copilot" order={viewMode === 'three_all' ? 3 : 2} className={`overflow-hidden relative ${viewMode === 'two_agents_chat' ? 'hidden' : ''}`}>
<Copilot
ref={copilotRef}
projectId={projectId}
workflow={state.present.workflow}
dispatch={dispatch}
chatContext={
state.present.selection &&
(state.present.selection.type === "agent" ||
state.present.selection.type === "tool" ||
state.present.selection.type === "prompt")
? {
type: state.present.selection.type,
name: state.present.selection.name
}
: chatMessages.length > 0
? { type: 'chat', messages: chatMessages }
: undefined
}
isInitialState={isInitialState}
dataSources={dataSources}
activePanel={activePanel}
onTogglePanel={handleTogglePanel}
/>
{/* Config overlay above Copilot when agents + skipper layout is active */}
{state.present.selection && viewMode === 'two_agents_skipper' && (
<div className="absolute inset-0 z-10">
<div className="h-full overflow-auto">
{state.present.selection?.type === "agent" && <AgentConfig
key={`overlay2-agent-${state.present.workflow.agents.findIndex(agent => agent.name === state.present.selection!.name)}-${configKey}`}
projectId={projectId}
workflow={state.present.workflow}
agent={state.present.workflow.agents.find((agent) => agent.name === state.present.selection!.name)!}
usedAgentNames={new Set(state.present.workflow.agents.filter((agent) => agent.name !== state.present.selection!.name).map((agent) => agent.name))}
usedPipelineNames={new Set((state.present.workflow.pipelines || []).map((pipeline) => pipeline.name))}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
prompts={state.present.workflow.prompts}
dataSources={dataSources}
handleUpdate={(update) => { dispatchGuarded({ type: "update_agent", name: state.present.selection!.name, agent: update }); }}
handleClose={handleUnselectAgent}
useRag={useRag}
triggerCopilotChat={triggerCopilotChat}
eligibleModels={eligibleModels === "*" ? "*" : eligibleModels.agentModels}
onOpenDataSourcesModal={handleOpenDataSourcesModal}
/>}
{state.present.selection?.type === "tool" && (() => {
const selectedTool = state.present.workflow.tools.find(
(tool) => tool.name === state.present.selection!.name
);
return <ToolConfig
key={`overlay2-${state.present.selection.name}-${configKey}`}
tool={selectedTool!}
usedToolNames={new Set([
...state.present.workflow.tools.filter((tool) => tool.name !== state.present.selection!.name).map((tool) => tool.name),
])}
handleUpdate={(update) => { dispatchGuarded({ type: "update_tool", name: state.present.selection!.name, tool: update }); }}
handleClose={handleUnselectTool}
/>;
})()}
{state.present.selection?.type === "prompt" && <PromptConfig
key={`overlay2-${state.present.selection.name}-${configKey}`}
prompt={state.present.workflow.prompts.find((prompt) => prompt.name === state.present.selection!.name)!}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
prompts={state.present.workflow.prompts}
usedPromptNames={new Set(state.present.workflow.prompts.filter((prompt) => prompt.name !== state.present.selection!.name).map((prompt) => prompt.name))}
handleUpdate={(update) => { dispatchGuarded({ type: "update_prompt", name: state.present.selection!.name, prompt: update }); }}
handleClose={handleUnselectPrompt}
/>}
{state.present.selection?.type === "datasource" && <DataSourceConfig
key={`overlay2-${state.present.selection.name}-${configKey}`}
dataSourceId={state.present.selection.name}
handleClose={() => dispatch({ type: "unselect_datasource" })}
onDataSourceUpdate={onDataSourcesUpdated}
/>}
{state.present.selection?.type === "pipeline" && <PipelineConfig
key={`overlay2-${state.present.selection.name}-${configKey}`}
projectId={projectId}
workflow={state.present.workflow}
pipeline={state.present.workflow.pipelines?.find((pipeline) => pipeline.name === state.present.selection!.name)!}
usedPipelineNames={new Set((state.present.workflow.pipelines || []).filter((pipeline) => pipeline.name !== state.present.selection!.name).map((pipeline) => pipeline.name))}
usedAgentNames={new Set(state.present.workflow.agents.map((agent) => agent.name))}
agents={state.present.workflow.agents}
pipelines={state.present.workflow.pipelines || []}
handleUpdate={handleUpdatePipeline.bind(null, state.present.selection.name)}
handleClose={() => dispatch({ type: "unselect_pipeline" })}
/>}
{state.present.selection?.type === "visualise" && (
<Panel title={<div className="flex items-center justify-between w-full"><div className="text-base font-semibold text-gray-900 dark:text-gray-100">Agent Graph Visualizer</div><CustomButton variant="secondary" size="sm" onClick={handleHideVisualise} showHoverContent={true} hoverContent="Close"><XIcon className="w-4 h-4" /></CustomButton></div>}>
<div className="h-full overflow-hidden">
<AgentGraphVisualizer workflow={state.present.workflow} />
</div>
</Panel>
)}
</div>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
)}
{USE_PRODUCT_TOUR && showTour && (
<ProductTour
projectId={projectId}
@ -1976,7 +2301,7 @@ export function WorkflowEditor({
forceStart
stepsOverride={[
{ target: 'playground', title: 'Step 1/2', content: 'Chat with your assistant to test it. Send messages, watch tool calls in action, and debug agent flows.' },
{ target: 'copilot', title: 'Step 2/2', content: 'Ask Copilot to improve your agents based on test results. Use “Fix” and “Explain” to iterate quickly.' },
{ target: 'copilot', title: 'Step 2/2', content: 'Ask Copilot to improve your agents based on test results. Use "Fix" and "Explain" to iterate quickly.' },
]}
onStepChange={(index) => {
if (index === 0) setActivePanel('playground');