Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0f88abb
feat(LandingScreen): wire Build with AI path — dismiss screen, open A…
lmac-1 Jun 25, 2026
f4738a9
feat(AIAssistantPanel): lock panel close while isNewWorkflow — hide c…
lmac-1 Jun 25, 2026
9fde854
feat(useAIWorkflowApplications): route validation errors to chat on n…
lmac-1 Jun 25, 2026
f4c031d
test(build-with-ai): add coverage for new-workflow AI path behaviours
lmac-1 Jun 25, 2026
08711ca
fix(handleApplyWorkflow): skip fit-view and add Retry button when sav…
lmac-1 Jun 25, 2026
555ab9c
fix(streaming-dedup): prevent unsaved changes indicator after AI appl…
lmac-1 Jun 29, 2026
d085c22
feat(MessageList): show validation errors inline, generic errors with…
lmac-1 Jun 29, 2026
0c29047
fix(useAIWorkflowApplications): reset streaming ref on session load; …
lmac-1 Jun 29, 2026
d86f053
fix(new-workflow): block URL params from corrupting landing screen state
lmac-1 Jun 30, 2026
8c28f9d
fix(useAIWorkflowApplications): retry save failure without re-applyin…
lmac-1 Jun 30, 2026
f08d347
test(MessageList): update error rendering assertions for non-empty co…
lmac-1 Jun 30, 2026
747078c
fix(tests): add saveWorkflow and isNewWorkflow to useAIWorkflowApplic…
lmac-1 Jun 30, 2026
b77e0fc
fix(useAIWorkflowApplications): reset appliedViaStreamingRef on apply…
lmac-1 Jun 30, 2026
31f9899
fix(useAIWorkflowApplications): handle retry save failure with error …
lmac-1 Jun 30, 2026
8916cd3
fix(useShowLandingScreen): gate on reactive isNewWorkflow to fix slow…
lmac-1 Jun 30, 2026
d133168
fix(useAIWorkflowApplications): handle null return from saveWorkflow …
lmac-1 Jun 30, 2026
a4dadc4
fix(useAIWorkflowApplications): reset appliedViaStreamingRef on save …
lmac-1 Jun 30, 2026
05644c6
refactor(useAIWorkflowApplications): remove unused handleApplyWorkflo…
lmac-1 Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading
Loading