Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ and this project adheres to

### Changed

- Global chat can now change multiple workflow steps in a single response. It
receives a full workflow YAML from the Apollo AI server with each step's job
code embedded, and applies the changes together. When a step is open in the
editor, its diff is previewed before applying; previewing several step diffs
at once is a follow-up.
[#4890](https://github.com/OpenFn/lightning/issues/4890)
- Redesigned the trigger inspector in the collaborative editor: selecting a
trigger now opens a read-only resting panel with an **Edit** button that leads
into a guided wizard (Choose → Configure → Finish), replacing the previous
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -560,36 +560,55 @@ export function AIAssistantPanelWrapper({
);

// Hook to handle workflow/job code application logic
const { handleApplyWorkflow, handlePreviewJobCode, handleApplyJobCode } =
useAIWorkflowApplications({
sessionId,
page: aiMode?.page || 'workflow_template',
currentSession:
sessionId && messages.length > 0
? {
messages,
workflowTemplateContext,
}
: null,
currentUserId: user?.id,
aiMode,
workflowActions: {
importWorkflow,
startApplyingWorkflow,
doneApplyingWorkflow,
startApplyingJobCode,
doneApplyingJobCode,
updateJob,
},
monacoRef,
jobs,
canApplyChanges,
connectionState,
setPreviewingMessageId,
previewingMessageId,
setApplyingMessageId,
appliedMessageIdsRef,
});
const {
handleApplyWorkflow,
handlePreviewJobCode,
handlePreviewGlobalStep,
handleApplyJobCode,
} = useAIWorkflowApplications({
sessionId,
page: aiMode?.page || 'workflow_template',
currentSession:
sessionId && messages.length > 0
? {
messages,
workflowTemplateContext,
}
: null,
currentUserId: user?.id,
aiMode,
workflowActions: {
importWorkflow,
startApplyingWorkflow,
doneApplyingWorkflow,
startApplyingJobCode,
doneApplyingJobCode,
updateJob,
},
monacoRef,
jobs,
canApplyChanges,
connectionState,
setPreviewingMessageId,
previewingMessageId,
setApplyingMessageId,
appliedMessageIdsRef,
});

// Route auto-preview to the right handler: global messages carry a full
// workflow YAML (the open step's diff is extracted from it), job-code
// messages carry the job body directly.
const handleAutoPreview = useCallback(
(code: string, messageId: string) => {
const message = messages.find(m => m.id === messageId);
if (message?.from_global) {
handlePreviewGlobalStep(code, messageId);
} else {
handlePreviewJobCode(code, messageId);
}
},
[messages, handlePreviewGlobalStep, handlePreviewJobCode]
);

// Auto-preview job code when AI responds with code
// Only for the user who authored the triggering message
Expand All @@ -599,7 +618,7 @@ export function AIAssistantPanelWrapper({
? { id: sessionId, session_type: 'workflow_template', messages }
: null,
currentUserId: user?.id,
onPreview: handlePreviewJobCode,
onPreview: handleAutoPreview,
});

// Auto-apply streaming changes as soon as they arrive (before text finishes)
Expand Down Expand Up @@ -707,7 +726,12 @@ export function AIAssistantPanelWrapper({
messages={messages}
isLoading={isLoading}
onApplyWorkflow={
aiMode?.page === 'workflow_template' && !isApplyingWorkflow
(aiMode?.page === 'workflow_template' ||
// Global messages apply the full workflow even while a
// job is open; handleApplyWorkflow still no-ops for
// non-global messages outside workflow_template mode.
messages.some(m => m.from_global && m.code)) &&
!isApplyingWorkflow
? (yaml, messageId) => {
void handleApplyWorkflow(yaml, messageId);
}
Expand All @@ -723,6 +747,8 @@ export function AIAssistantPanelWrapper({
onPreviewJobCode={
aiMode?.page === 'job_code' ? handlePreviewJobCode : undefined
}
onPreviewGlobalStep={handlePreviewGlobalStep}
canPreviewGlobalStep={aiMode?.page === 'job_code'}
applyingMessageId={
// If anyone is applying (including other users), pass the message ID
// to show "APPLYING..." state. Prioritize stored message ID from store,
Expand Down
18 changes: 16 additions & 2 deletions assets/js/collaborative-editor/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,10 @@ interface MessageListProps {
onApplyWorkflow?: ((yaml: string, messageId: string) => void) | undefined;
onApplyJobCode?: ((code: string, messageId: string) => void) | undefined;
onPreviewJobCode?: ((code: string, messageId: string) => void) | undefined;
/** Per-step diff preview for global messages (extracts the open job's body from the YAML) */
onPreviewGlobalStep?: ((yaml: string, messageId: string) => void) | undefined;
/** Whether a job is open in the IDE, enabling preview for global messages */
canPreviewGlobalStep?: boolean;
applyingMessageId?: string | null | undefined;
previewingMessageId?: string | null | undefined;
showAddButtons?: boolean;
Expand All @@ -412,6 +416,8 @@ export function MessageList({
onApplyWorkflow,
onApplyJobCode,
onPreviewJobCode,
onPreviewGlobalStep,
canPreviewGlobalStep = false,
applyingMessageId,
previewingMessageId,
showAddButtons = false,
Expand Down Expand Up @@ -618,7 +624,10 @@ export function MessageList({
code={message.code}
showAdd={showAddButtons}
showApply={showApplyButton}
showPreview={!!message.job_id}
showPreview={
!!message.job_id ||
(!!message.from_global && canPreviewGlobalStep)
}
onApply={() => {
if (message.job_id) {
onApplyJobCode?.(message.code!, message.id);
Expand All @@ -627,7 +636,12 @@ export function MessageList({
}
}}
onPreview={() => {
onPreviewJobCode?.(message.code!, message.id);
if (message.from_global) {
// Per-step diff from the full workflow YAML
onPreviewGlobalStep?.(message.code!, message.id);
} else {
onPreviewJobCode?.(message.code!, message.id);
}
}}
isApplying={!!applyingMessageId}
isPreviewActive={previewingMessageId === message.id}
Expand Down
102 changes: 100 additions & 2 deletions assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,14 @@ export function useAIWorkflowApplications({
*/
const handleApplyWorkflow = useCallback(
async (yaml: string, messageId: string) => {
if (!aiMode || aiMode.page !== 'workflow_template') {
if (!aiMode) return;
// Global messages carry a full workflow YAML and may be applied even
// while a job is open (job_code mode). Non-global workflow chat keeps
// the workflow_template-only guard so its Apply stays a no-op when a
// job is open.
const isGlobal = !!currentSession?.messages.find(m => m.id === messageId)
?.from_global;
if (aiMode.page !== 'workflow_template' && !isGlobal) {
console.error(
'[AI Assistant] Cannot apply workflow - not in workflow mode',
{
Expand All @@ -179,6 +186,15 @@ export function useAIWorkflowApplications({
);
return;
}

// A global message applied while a step is open leaves an active diff in
// the open step. Clear it so the editor returns to an editable state.
const monaco = monacoRef?.current;
if (previewingMessageId && monaco) {
monaco.clearDiff();
setPreviewingMessageId(null);
}

setApplyingMessageId(messageId);

// Signal to all collaborators that we're starting to apply
Expand Down Expand Up @@ -218,12 +234,16 @@ export function useAIWorkflowApplications({
}
},
[
aiMode?.page,
aiMode,
currentSession,
importWorkflow,
startApplyingWorkflow,
doneApplyingWorkflow,
jobs,
setApplyingMessageId,
monacoRef,
previewingMessageId,
setPreviewingMessageId,
]
);

Expand Down Expand Up @@ -295,6 +315,83 @@ export function useAIWorkflowApplications({
[aiMode, jobs, previewingMessageId, monacoRef, setPreviewingMessageId]
);

/**
* Preview the open job's diff from a global full-workflow YAML message
*
* Mirrors handlePreviewJobCode, but extracts the open job's body from the
* workflow YAML (global messages carry the whole workflow in `code`).
* Shows a diff only when the open step's body actually changed; clears any
* stale diff otherwise.
*/
const handlePreviewGlobalStep = useCallback(
(yaml: string, messageId: string) => {
if (!aiMode || aiMode.page !== 'job_code') return; // only when a step is open
const jobId = (aiMode.context as JobCodeContext).job_id;
if (!jobId) return;

// Same dedup guards as handlePreviewJobCode
if (previewingMessageId === messageId) return;
if (previewingMessageId === '__streaming__') {
setPreviewingMessageId(messageId);
return;
}

const currentJob = jobs.find(j => j.id === jobId);
const currentBody = currentJob?.body ?? '';

let newBody: string | undefined;
try {
const spec = parseWorkflowYAML(yaml);
// ids from the YAML are preserved, so we match the open step by id
const state = convertWorkflowSpecToState(spec);
newBody = state.jobs.find(j => j.id === jobId)?.body;
} catch (error) {
console.error(
'[AI Assistant] Failed to parse global workflow YAML:',
error
);
notifications.alert({
title: 'Could not preview step',
description:
error instanceof Error
? error.message
: 'The AI server returned invalid workflow YAML.',
});
return;
}

if (newBody === undefined) {
// Open step's id wasn't in the YAML, so the server likely didn't preserve it
console.warn(
'[AI Assistant] Open step not found in global workflow YAML',
{ jobId }
);
notifications.warning({
title: 'Could not preview this step',
description: `Step "${
currentJob?.name ?? jobId
}" was not found in the AI response (id: ${jobId}). Its ID may not have been preserved by the server.`,
});
if (previewingMessageId) monacoRef?.current?.clearDiff();
return;
}

if (newBody === currentBody) {
// open step genuinely unchanged -> ensure no stale diff is shown
if (previewingMessageId) monacoRef?.current?.clearDiff();
return;
}

const monaco = monacoRef?.current;
if (previewingMessageId && monaco) monaco.clearDiff();
if (monaco) {
monaco.showDiff(currentBody, newBody);
setPreviewingMessageId(messageId);
}
},
[aiMode, jobs, previewingMessageId, monacoRef, setPreviewingMessageId]
);

/**
* Apply job code to Y.Doc
*
Expand Down Expand Up @@ -453,6 +550,7 @@ export function useAIWorkflowApplications({
return {
handleApplyWorkflow,
handlePreviewJobCode,
handlePreviewGlobalStep,
handleApplyJobCode,
};
}
7 changes: 4 additions & 3 deletions assets/js/collaborative-editor/hooks/useAutoPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,10 @@ export function useAutoPreview({
new Date(b.inserted_at).getTime() - new Date(a.inserted_at).getTime()
)[0];

if (!latestCodeMessage || !latestCodeMessage.job_id) {
return;
}
if (!latestCodeMessage) return;
// Global messages have no job_id but carry a full workflow YAML; the
// onPreview callback handles extracting the open step's body from it.
if (!latestCodeMessage.job_id && !latestCodeMessage.from_global) return;

// Skip if we've already auto-previewed this message
if (stateRef.current.lastAutoPreviewedMessageId === latestCodeMessage.id) {
Expand Down
15 changes: 12 additions & 3 deletions assets/js/collaborative-editor/lib/AIChannelRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -915,14 +915,23 @@ export class AIChannelRegistry {
if (context.workflow_id) {
params['workflow_id'] = context.workflow_id;
}
if (context.code) {
params['code'] = context.code;
}
if (context.content) {
params['content'] = context.content;
}
}

// Workflow YAML (applicable to both session types). For global chat the
// `code` slot carries the FULL serialized workflow YAML (every step body
// embedded), not a single job's code. When a step is open the context is
// JobCodeContext-shaped and took the branch above, which does not forward
// `code`. Forwarding it here (outside the branch) ensures the YAML reaches
// Apollo on the *first* turn — the only message sent via the channel join.
// Later turns go through `new_message` and were never affected. Plain job
// chat never sets `context.code`, so this is a no-op there.
if ('code' in context && context.code) {
params['code'] = context.code;
}

// Global assistant flags (applicable to both session types)
if ('use_global_assistant' in context && context.use_global_assistant) {
params['use_global_assistant'] = true;
Expand Down
13 changes: 13 additions & 0 deletions assets/js/collaborative-editor/types/ai-assistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export interface Message {
user_id?: string;
user?: MessageUser | null;
job_id?: string;
/**
* True when this message came from the global AI assistant. Global
* messages carry a full workflow YAML in `code` and never a `job_id`.
*/
from_global?: boolean;
}

/**
Expand All @@ -72,6 +77,10 @@ export interface JobCodeContext {
job_body?: string;
job_adaptor?: string;
workflow_id?: string;

// Full serialized workflow YAML attached by global chat (sent as the message
// `code`). Present even with a step open, so it lives on the job context too.
code?: string;
}

/**
Expand Down Expand Up @@ -101,6 +110,10 @@ export type WorkflowTemplateContext =

workflow_id?: string;
project_id: string;

// Full serialized workflow YAML attached by global chat (sent as the
// message `code`), present even when a step is open.
code?: string;
};

/**
Expand Down
Loading