Skip to content
Draft
9 changes: 6 additions & 3 deletions assets/js/collaborative-editor/CollaborativeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,19 @@ function LandingScreenWrapper({
aiAssistantEnabled: boolean;
}) {
const showLandingScreen = useShowLandingScreen();
const { openYAMLImportModal } = useUICommands();
const { openYAMLImportModal, dismissLandingScreen, openAIAssistantPanel } =
useUICommands();

if (!showLandingScreen) return null;

return (
<>
{/* TODO-AI-FIRST Stubs — wired up in Issues #4857 (Build with AI), #4858 (Browse Templates) */}
<LandingScreen
aiAssistantEnabled={aiAssistantEnabled}
onBuildWithAI={() => {}}
onBuildWithAI={(prompt: string) => {
dismissLandingScreen();
openAIAssistantPanel(prompt);
}}
onBuildFromScratch={() => {}}
onBrowseTemplates={() => {}}
onImportYAML={openYAMLImportModal}
Expand Down
46 changes: 25 additions & 21 deletions assets/js/collaborative-editor/components/AIAssistantPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useState, useRef } from 'react';

import { cn } from '#/utils/cn';

import { Tooltip } from '../../components/Tooltip';
import {
useAIStorageKey,
useAISessionType,
Expand All @@ -15,11 +16,10 @@ import { useSelectedStepId, useSelectedRunId } from '../hooks/useHistory';
import { ChatInput } from './ChatInput';
import { DisclaimerScreen } from './DisclaimerScreen';
import { SessionList } from './SessionList';
import { Tooltip } from '../../components/Tooltip';

interface AIAssistantPanelProps {
isOpen: boolean;
onClose: () => void;
onClose?: () => void;
onNewConversation?: () => void;
onSessionSelect?: (sessionId: string) => void;
onShowSessions?: () => void;
Expand Down Expand Up @@ -233,7 +233,7 @@ export function AIAssistantPanel({
if (onShowSessions) {
onShowSessions();
}
} else {
} else if (onClose) {
onClose();
}
};
Expand Down Expand Up @@ -390,27 +390,31 @@ export function AIAssistantPanel({
</div>
)}
</div>
<Tooltip
content={sessionId ? 'Close current session' : 'Close assistant'}
>
<button
type="button"
onClick={handleClose}
className={cn(
'inline-flex items-center justify-center',
'h-8 w-8 rounded-md',
'text-gray-400 hover:text-gray-600 hover:bg-gray-100',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
'transition-all duration-150',
'flex-shrink-0'
)}
aria-label={
{onClose && (
<Tooltip
content={
sessionId ? 'Close current session' : 'Close assistant'
}
>
<span className="hero-x-mark h-5 w-5" />
</button>
</Tooltip>
<button
type="button"
onClick={handleClose}
className={cn(
'inline-flex items-center justify-center',
'h-8 w-8 rounded-md',
'text-gray-400 hover:text-gray-600 hover:bg-gray-100',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
'transition-all duration-150',
'shrink-0'
)}
aria-label={
sessionId ? 'Close current session' : 'Close assistant'
}
>
<span className="hero-x-mark h-5 w-5" />
</button>
</Tooltip>
)}
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import {
useWorkflowState,
} from '../hooks/useWorkflow';
import { useKeyboardShortcut } from '../keyboard';
import type { JobCodeContext } from '../types/ai-assistant';
import type { JobCodeContext, Message } from '../types/ai-assistant';
import { Z_INDEX } from '../utils/constants';
import {
prepareWorkflowForSerialization,
Expand Down Expand Up @@ -103,6 +103,9 @@ export function AIAssistantPanelWrapper({
const isPinnedVersion =
currentVersion !== undefined && currentVersion !== null;

const { isReadOnly } = useWorkflowReadOnly();
const isNewWorkflow = useIsNewWorkflow();

// Track IDE state changes to re-focus chat input when IDE closes
const isIDEOpen = params.panel === 'editor';
const [focusTrigger, setFocusTrigger] = useState(0);
Expand All @@ -128,7 +131,7 @@ export function AIAssistantPanelWrapper({
toggleAIAssistantPanel();
},
0,
{ enabled: !isPinnedVersion && aiAssistantEnabled }
{ enabled: !isPinnedVersion && aiAssistantEnabled && !isNewWorkflow }
);

const aiStore = useAIStore();
Expand Down Expand Up @@ -157,10 +160,7 @@ export function AIAssistantPanelWrapper({
const workflow = useWorkflowState(state => state.workflow);
const limits = useLimits();

// Check readonly state and new workflow status
// AI can apply changes if: not readonly OR is a new workflow (being created)
const { isReadOnly } = useWorkflowReadOnly();
const isNewWorkflow = useIsNewWorkflow();
const canApplyChanges = !isReadOnly || isNewWorkflow;
const isWriteDisabled = !canApplyChanges;

Expand Down Expand Up @@ -548,6 +548,7 @@ export function AIAssistantPanelWrapper({
startApplyingJobCode,
doneApplyingJobCode,
updateJob,
saveWorkflow,
} = useWorkflowActions();

// Get applying state from workflow store for disabling Apply button across all users
Expand All @@ -559,6 +560,24 @@ export function AIAssistantPanelWrapper({
state => state.applyingJobCodeMessageId
);

const onValidationError = useCallback(
(errorMessage: string) => {
const message: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: errorMessage,
status: 'error',
inserted_at: new Date().toISOString(),
};
aiStore._addMessage(message);
},
[aiStore]
);

// Track whether we applied via streaming so the auto-apply effect in the
// hook can skip the duplicate when the final new_message arrives
const appliedViaStreamingRef = useRef(false);

// Hook to handle workflow/job code application logic
const { handleApplyWorkflow, handlePreviewJobCode, handleApplyJobCode } =
useAIWorkflowApplications({
Expand All @@ -573,13 +592,16 @@ export function AIAssistantPanelWrapper({
: null,
currentUserId: user?.id,
aiMode,
isNewWorkflow,
onValidationError,
workflowActions: {
importWorkflow,
startApplyingWorkflow,
doneApplyingWorkflow,
startApplyingJobCode,
doneApplyingJobCode,
updateJob,
saveWorkflow,
},
monacoRef,
jobs,
Expand All @@ -589,6 +611,7 @@ export function AIAssistantPanelWrapper({
previewingMessageId,
setApplyingMessageId,
appliedMessageIdsRef,
appliedViaStreamingRef,
});

// Auto-preview job code when AI responds with code
Expand All @@ -608,9 +631,6 @@ export function AIAssistantPanelWrapper({
const appliedStreamingChangesRef = useRef<Record<string, unknown> | null>(
null
);
// Track whether we applied via streaming so we can skip the duplicate
// auto-apply when the final new_message arrives
const appliedViaStreamingRef = useRef(false);
useEffect(() => {
if (!streamingChanges || !canApplyChanges) return;
// Avoid re-applying the same streaming changes object
Expand All @@ -626,7 +646,6 @@ export function AIAssistantPanelWrapper({
} else if (aiMode?.page === 'job_code' && 'code' in streamingChanges) {
const code = streamingChanges['code'] as string;
if (code) {
appliedViaStreamingRef.current = true;
handlePreviewJobCode(code, '__streaming__');
}
}
Expand All @@ -638,22 +657,6 @@ export function AIAssistantPanelWrapper({
handlePreviewJobCode,
]);

// When a new assistant message with code arrives after we already applied
// via streaming, mark it as already applied to prevent duplicate auto-apply
// and update previewingMessageId to the real ID to prevent diff flicker
useEffect(() => {
if (!appliedViaStreamingRef.current) return;

const latestAssistantMessage = [...messages]
.reverse()
.find(m => m.role === 'assistant' && m.code && m.status === 'success');

if (latestAssistantMessage) {
appliedMessageIdsRef.current.add(latestAssistantMessage.id);
appliedViaStreamingRef.current = false;
}
}, [messages, appliedMessageIdsRef]);

return (
<div
className="flex h-full flex-shrink-0"
Expand Down Expand Up @@ -683,7 +686,7 @@ export function AIAssistantPanelWrapper({
<div className="flex-1 overflow-hidden">
<AIAssistantPanel
isOpen={isAIAssistantPanelOpen}
onClose={handleClosePanel}
onClose={isNewWorkflow ? undefined : handleClosePanel}
onNewConversation={handleNewConversation}
onSessionSelect={handleSessionSelect}
onShowSessions={handleShowSessions}
Expand Down
96 changes: 57 additions & 39 deletions assets/js/collaborative-editor/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -545,18 +545,34 @@ export function MessageList({
}
>
<div className="space-y-3">
<MarkdownContent
content={
isStreaming(message)
? message.content.replace(/\n+$/, '')
: message.content
}
showAddButtons={
!isStreaming(message) && showAddButtons && !message.code
}
isWriteDisabled={isWriteDisabled}
className={PROSE_CLASSES}
/>
{message.status === 'error' &&
!isStreaming(message) &&
message.content.trim() ? (
<div
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2"
data-testid="ai-validation-error"
>
<div className="flex items-start gap-2">
<span className="hero-exclamation-circle h-4 w-4 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-700 leading-relaxed">
{message.content}
</p>
</div>
</div>
) : (
<MarkdownContent
content={
isStreaming(message)
? message.content.replace(/\n+$/, '')
: message.content
}
showAddButtons={
!isStreaming(message) && showAddButtons && !message.code
}
isWriteDisabled={isWriteDisabled}
className={PROSE_CLASSES}
/>
)}

{/* Status (e.g. "Generating code...") Apollo may stream
after the text answer, while we wait for code. Same
Expand Down Expand Up @@ -645,33 +661,35 @@ export function MessageList({
</div>
)}

{!isStreaming(message) && message.status === 'error' && (
<div
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-50 border border-red-200"
data-testid="ai-error-message"
>
<span className="hero-exclamation-circle h-4 w-4 text-red-600 flex-shrink-0" />
<span className="text-sm text-red-700 flex-1">
Failed to send message. Please try again.
</span>
{onRetryMessage && (
<button
type="button"
onClick={() => onRetryMessage(message.id)}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5',
'text-xs font-medium rounded-md',
'bg-red-100 text-red-700 hover:bg-red-200',
'transition-colors duration-150',
'focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1'
)}
>
<span className="hero-arrow-path h-3.5 w-3.5" />
Retry
</button>
)}
</div>
)}
{!isStreaming(message) &&
message.status === 'error' &&
!message.content.trim() && (
<div
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-50 border border-red-200"
data-testid="ai-error-message"
>
<span className="hero-exclamation-circle h-4 w-4 text-red-600 flex-shrink-0" />
<span className="text-sm text-red-700 flex-1">
Failed to send message. Please try again.
</span>
{onRetryMessage && (
<button
type="button"
onClick={() => onRetryMessage(message.id)}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5',
'text-xs font-medium rounded-md',
'bg-red-100 text-red-700 hover:bg-red-200',
'transition-colors duration-150',
'focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1'
)}
>
<span className="hero-arrow-path h-3.5 w-3.5" />
Retry
</button>
)}
</div>
)}

{!isStreaming(message) && message.status === 'processing' && (
<div className="flex items-center gap-2 text-gray-600">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ export function WorkflowEditor({
{/* Show placeholder when workflow is empty and landing screen is not covering it */}
{isNewWorkflow &&
!showLandingScreen &&
!isAIAssistantPanelOpen &&
workflow.jobs.length === 0 &&
workflow.triggers.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
Expand Down
Loading