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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ and this project adheres to
### Added

- AI assistant responses now stream in real-time
- Add experimental global AI assistant option for users with experimental
features enabled [#4532](https://github.com/OpenFn/lightning/issues/4532)
[#4517](https://github.com/OpenFn/lightning/pull/4517)
- Allow users to export all collection items as a JSON file.
[#4527](https://github.com/OpenFn/lightning/issues/4527)
Expand Down
26 changes: 22 additions & 4 deletions assets/js/collaborative-editor/components/AIAssistantPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,20 @@ interface AIAssistantPanelProps {
* AI assistant limit information
*/
aiLimit?: { allowed: boolean; message: string | null } | null;
/** Show the experimental global assistant toggle */
showGlobalAssistantOption?: boolean;
/** Whether the global assistant checkbox is currently checked */
isGlobalAssistantActive?: boolean;
/** Callback when global assistant checkbox changes */
onGlobalAssistantChange?: (active: boolean) => void;
}

interface MessageOptions {
attach_code?: boolean;
attach_logs?: boolean;
attach_io_data?: boolean;
step_id?: string;
use_global_assistant?: boolean;
}

/**
Expand Down Expand Up @@ -103,6 +110,9 @@ export function AIAssistantPanel({
onAcceptDisclaimer,
connectionState = 'connected',
aiLimit = null,
showGlobalAssistantOption = false,
isGlobalAssistantActive = false,
onGlobalAssistantChange,
}: AIAssistantPanelProps) {
const [view, setView] = useState<'chat' | 'sessions'>(
sessionId ? 'chat' : 'sessions'
Expand Down Expand Up @@ -267,12 +277,18 @@ export function AIAssistantPanel({
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
page === 'job_code'
? 'bg-blue-100 text-blue-800'
: 'bg-purple-100 text-purple-800'
isGlobalAssistantActive
? 'bg-amber-100 text-amber-800'
: page === 'job_code'
? 'bg-blue-100 text-blue-800'
: 'bg-purple-100 text-purple-800'
)}
>
{page === 'job_code' ? 'Job' : 'Workflow'}
{isGlobalAssistantActive
? 'Global (experimental)'
: page === 'job_code'
? 'Job'
: 'Workflow'}
</span>
)}
</div>
Expand Down Expand Up @@ -433,6 +449,8 @@ export function AIAssistantPanel({
(!hasSessionContext || !hasCompletedSessionLoad))
}
showJobControls={page === 'job_code'}
showGlobalAssistantOption={showGlobalAssistantOption}
onGlobalAssistantChange={onGlobalAssistantChange}
storageKey={storageKey}
enableAutoFocus={
isOpen &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { useAIWorkflowApplications } from '../hooks/useAIWorkflowApplications';
import { useAutoPreview } from '../hooks/useAutoPreview';
import { useResizablePanel } from '../hooks/useResizablePanel';
import {
useExperimentalFeaturesEnabled,
useHasReadAIDisclaimer,
useIsNewWorkflow,
useLimits,
Expand Down Expand Up @@ -133,6 +134,8 @@ export function AIAssistantPanelWrapper({
const connectionState = useAIConnectionState();
const sessionContextLoaded = useSessionContextLoaded();
const hasReadDisclaimer = useHasReadAIDisclaimer();
const experimentalFeaturesEnabled = useExperimentalFeaturesEnabled();
const [isGlobalAssistantActive, setIsGlobalAssistantActive] = useState(false);
const markAIDisclaimerRead = useMarkAIDisclaimerRead();
const workflowTemplateContext = useAIWorkflowTemplateContext();
const project = useProject();
Expand Down Expand Up @@ -309,6 +312,7 @@ export function AIAssistantPanelWrapper({
attach_logs?: boolean;
attach_io_data?: boolean;
step_id?: string;
use_global_assistant?: boolean;
}
) => {
const currentState = aiStore.getSnapshot();
Expand Down Expand Up @@ -355,10 +359,16 @@ export function AIAssistantPanelWrapper({
...(messageOptions?.attach_logs && { attach_logs: true }),
...(messageOptions?.attach_io_data && { attach_io_data: true }),
...(messageOptions?.step_id && { step_id: messageOptions.step_id }),
...(messageOptions?.use_global_assistant && {
use_global_assistant: true,
}),
};

// Add workflow YAML if in workflow mode
if (page === 'workflow_template') {
// Add workflow YAML if in workflow mode or global assistant
if (
page === 'workflow_template' ||
messageOptions?.use_global_assistant
) {
const workflowData = prepareWorkflowForSerialization(
workflow,
jobs,
Expand All @@ -372,6 +382,18 @@ export function AIAssistantPanelWrapper({
finalContext = { ...finalContext, code: workflowYAML };
}
}

// Derive page for global assistant routing
if (messageOptions?.use_global_assistant) {
const jobName = (context as JobCodeContext)?.job_name;
const workflowName = workflow?.name || 'workflow';
finalContext = {
...finalContext,
page: jobName
? `workflows/${workflowName}/${jobName}`
: `workflows/${workflowName}`,
};
}
}

// Initialize store with context including content
Expand All @@ -398,12 +420,17 @@ export function AIAssistantPanelWrapper({
attach_io_data?: boolean;
step_id?: string;
code?: string;
use_global_assistant?: boolean;
page?: string;
}
| undefined = {
...messageOptions, // Include attach_code, attach_logs, attach_io_data, step_id
};

if (aiMode?.page === 'workflow_template') {
if (
aiMode?.page === 'workflow_template' ||
messageOptions?.use_global_assistant
) {
const workflowData = prepareWorkflowForSerialization(
workflow,
jobs,
Expand All @@ -418,6 +445,15 @@ export function AIAssistantPanelWrapper({
if (workflowYAML) {
options = { ...options, code: workflowYAML };
}

// Derive page for global assistant routing
if (messageOptions?.use_global_assistant) {
const jobName = (aiMode?.context as JobCodeContext)?.job_name;
const workflowName = workflow?.name || 'workflow';
options.page = jobName
? `workflows/${workflowName}/${jobName}`
: `workflows/${workflowName}`;
}
} else {
// important: determines what ai to be used
options = { ...aiMode?.context, ...options };
Expand Down Expand Up @@ -449,6 +485,10 @@ export function AIAssistantPanelWrapper({
[aiStore, retryMessageViaChannel]
);

const handleGlobalAssistantChange = useCallback((active: boolean) => {
setIsGlobalAssistantActive(active);
}, []);

const handleMarkDisclaimerRead = useCallback(() => {
// Persist to backend via workflow channel and update local state
markAIDisclaimerRead();
Expand Down Expand Up @@ -642,6 +682,9 @@ export function AIAssistantPanelWrapper({
showDisclaimer={sessionContextLoaded && !hasReadDisclaimer}
onAcceptDisclaimer={handleMarkDisclaimerRead}
aiLimit={limits.ai_assistant ?? null}
showGlobalAssistantOption={experimentalFeaturesEnabled}
isGlobalAssistantActive={isGlobalAssistantActive}
onGlobalAssistantChange={handleGlobalAssistantChange}
>
<MessageList
messages={messages}
Expand Down
72 changes: 72 additions & 0 deletions assets/js/collaborative-editor/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ interface ChatInputProps {
isDisabled?: boolean | undefined;
/** Show job-specific controls (attach code, attach logs) */
showJobControls?: boolean | undefined;
/** Show the experimental global assistant toggle */
showGlobalAssistantOption?: boolean | undefined;
/** Callback when global assistant checkbox changes */
onGlobalAssistantChange?: ((active: boolean) => void) | undefined;
/** Storage key for persisting checkbox preferences */
storageKey?: string | undefined;
/** Enable automatic focus management for the input */
Expand All @@ -36,6 +40,7 @@ interface MessageOptions {
attach_logs?: boolean;
attach_io_data?: boolean;
step_id?: string;
use_global_assistant?: boolean;
}

const MIN_TEXTAREA_HEIGHT = 52;
Expand All @@ -46,6 +51,8 @@ export function ChatInput({
isLoading = false,
isDisabled = false,
showJobControls = false,
showGlobalAssistantOption = false,
onGlobalAssistantChange,
storageKey,
enableAutoFocus = false,
focusTrigger,
Expand Down Expand Up @@ -96,6 +103,17 @@ export function ChatInput({
}
});

const [useGlobalAssistant, setUseGlobalAssistant] = useState(() => {
if (!storageKey) return false;
try {
return (
localStorage.getItem(`${storageKey}:use-global-assistant`) === 'true'
);
} catch {
return false;
}
});

const textareaRef = useRef<HTMLTextAreaElement>(null);
const isLoadingFromStorageRef = useRef(false);

Expand Down Expand Up @@ -143,6 +161,14 @@ export function ChatInput({
// Ignore localStorage errors
}

try {
const globalValue =
localStorage.getItem(`${storageKey}:use-global-assistant`) === 'true';
setUseGlobalAssistant(globalValue);
} catch {
// Ignore localStorage errors
}

setTimeout(() => {
isLoadingFromStorageRef.current = false;
}, 0);
Expand Down Expand Up @@ -181,6 +207,23 @@ export function ChatInput({
}
}, [attachIoData, storageKey]);

useEffect(() => {
if (!storageKey) return;
if (isLoadingFromStorageRef.current) return;
try {
localStorage.setItem(
`${storageKey}:use-global-assistant`,
String(useGlobalAssistant)
);
} catch {
// Ignore localStorage errors
}
}, [useGlobalAssistant, storageKey]);

useEffect(() => {
onGlobalAssistantChange?.(useGlobalAssistant);
}, [useGlobalAssistant, onGlobalAssistantChange]);

useEffect(() => {
if (enableAutoFocus && textareaRef.current) {
const timeoutId = setTimeout(() => {
Expand Down Expand Up @@ -216,6 +259,10 @@ export function ChatInput({
}
}

if (showGlobalAssistantOption && useGlobalAssistant) {
options.use_global_assistant = true;
}

onSendMessage?.(input.trim(), options);
setInput('');
};
Expand Down Expand Up @@ -422,6 +469,31 @@ export function ChatInput({
</span>
</div>
)}

{showGlobalAssistantOption && (
<Tooltip
content="Route messages to the experimental global assistant"
side="top"
>
<label className="flex items-center gap-1.5 group cursor-pointer">
<input
type="checkbox"
checked={useGlobalAssistant}
onChange={e =>
setUseGlobalAssistant(e.target.checked)
}
className={cn(
'w-3.5 h-3.5 rounded border-amber-400 text-amber-600',
'focus:ring-amber-500 focus:ring-offset-0',
'cursor-pointer'
)}
/>
<span className="text-[11px] font-medium text-amber-600 group-hover:text-amber-700">
Global assistant (experimental)
</span>
</label>
</Tooltip>
)}
</div>

<button
Expand Down
16 changes: 16 additions & 0 deletions assets/js/collaborative-editor/hooks/useSessionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,22 @@ export const useHasReadAIDisclaimer = (): boolean => {
);
};

/**
* Hook to check if the user has experimental features enabled
*/
export const useExperimentalFeaturesEnabled = (): boolean => {
const sessionContextStore = useSessionContextStore();

const selectExperimentalFeaturesEnabled = sessionContextStore.withSelector(
state => state.experimentalFeaturesEnabled
);

return useSyncExternalStore(
sessionContextStore.subscribe,
selectExperimentalFeaturesEnabled
);
};

/**
* Hook to get setHasReadAIDisclaimer action
* Returns function to update AI disclaimer read status (local state only)
Expand Down
8 changes: 8 additions & 0 deletions assets/js/collaborative-editor/lib/AIChannelRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,14 @@ export class AIChannelRegistry {
}
}

// Global assistant flags (applicable to both session types)
if ('use_global_assistant' in context && context.use_global_assistant) {
params['use_global_assistant'] = true;
}
if ('page' in context && context.page) {
params['page'] = context.page as string;
}

return params;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const createSessionContextStore = (
versionsError: null,
workflow_template: null,
hasReadAIDisclaimer: false,
experimentalFeaturesEnabled: false,
limits: {},
isNewWorkflow,
isLoading: false,
Expand Down Expand Up @@ -189,6 +190,8 @@ export const createSessionContextStore = (
draft.webhookAuthMethods = sessionContext.webhook_auth_methods;
draft.workflow_template = sessionContext.workflow_template;
draft.hasReadAIDisclaimer = sessionContext.has_read_ai_disclaimer;
draft.experimentalFeaturesEnabled =
sessionContext.experimental_features_enabled;
draft.limits = sessionContext.limits;
draft.isLoading = false;
draft.error = null;
Expand Down
3 changes: 3 additions & 0 deletions assets/js/collaborative-editor/types/ai-assistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ export interface MessageOptions {

code?: string;
errors?: string;

use_global_assistant?: boolean;
page?: string;
}

/**
Expand Down
2 changes: 2 additions & 0 deletions assets/js/collaborative-editor/types/sessionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export const SessionContextResponseSchema = z.object({
webhook_auth_methods: z.array(WebhookAuthMethodSchema),
workflow_template: WorkflowTemplateSchema.nullable(),
has_read_ai_disclaimer: z.boolean(),
experimental_features_enabled: z.boolean().optional().default(false),
limits: LimitsSchema.optional(),
workflow: BaseWorkflowSchema.optional(),
});
Expand All @@ -126,6 +127,7 @@ export interface SessionContextState {
versionsError: string | null;
workflow_template: WorkflowTemplate | null;
hasReadAIDisclaimer: boolean;
experimentalFeaturesEnabled: boolean;
limits: Limits;
isNewWorkflow: boolean;
isLoading: boolean;
Expand Down
Loading
Loading