Skip to content

Commit baeb59a

Browse files
authored
feat: add experimental global AI assistant option (#4567)
* feat: add global chat streaming endpoint and routing - Add global_chat_stream/2 to ApolloClient for /services/global_chat/stream - Add global_chat?/1 and process_global_message/2 to MessageProcessor - Route to global_chat when use_global_assistant is in session meta - Pass use_global_assistant and page through channel message_options - Add experimental_features_enabled to workflow channel get_context * feat: add query_global_stream and build_global_message - Add query_global_stream/3 using process_stream with build_global_message - build_global_message extracts code from attachments (job_code or workflow_yaml) - Context-aware: on job step prefers job_code, on overview prefers workflow_yaml - Resolves job_id from job_key in attachment by matching against workflow jobs * feat: add experimental features flag to frontend session context - Add experimental_features_enabled to SessionContextResponseSchema - Map to experimentalFeaturesEnabled in SessionContextStore - Add useExperimentalFeaturesEnabled hook - Add use_global_assistant and page to MessageOptions type * feat: add global assistant checkbox and UI wiring - Add Global assistant (experimental) checkbox to ChatInput with localStorage persistence and onGlobalAssistantChange callback - Pass use_global_assistant and page through AIChannelRegistry buildJoinParams - Wire checkbox through AIAssistantPanel with showGlobalAssistantOption, isGlobalAssistantActive, and onGlobalAssistantChange props - AIAssistantPanelWrapper includes workflow YAML and page when global assistant is active, derives page from workflow/job context - Badge shows "Global (experimental)" in amber when active * chore: add changelog entry for global AI assistant (#4532) * test: add experimental_features_enabled to session context test fixture The Zod schema adds a default value for the new optional field, so the parsed output includes it. Update the test fixture to match. * refactor: extract_global_code_and_job to reduce cyclomatic complexity Split build_global_message into smaller functions to satisfy credo's complexity threshold. * test: add coverage for global chat integration - ApolloClient: global_chat_stream payload, nil filtering, error handling - AiAssistant: query_global_stream SSE processing, job_code vs workflow_yaml extraction, job resolution from job_key, error handling - MessageProcessor: global_chat routing when use_global_assistant is set - AiAssistantChannel: session options and message options with global assistant - WorkflowChannel: experimental_features_enabled in get_context * test: add coverage for edge cases in build_global_message Cover workflow_yaml fallback on overview pages, nil job_key handling in resolve_job_from_key, and non-list attachments. * test: cover resolve_job_from_key with nil workflow_id * test: cover normalize_job_name catch-all with non-string job_key * feat: use job_key from global chat to identify job code messages When the global chat routes to job_code_agent and the job can't be resolved to a DB record, store the job_key in message meta as from_global_job_code so the frontend can render "Generated Job Code" with a diff view instead of a generic workflow card.
1 parent bf0687f commit baeb59a

File tree

20 files changed

+1268
-12
lines changed

20 files changed

+1268
-12
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ and this project adheres to
3939
snapshots are also cleaned up after expired requests are removed.
4040
[#4504](https://github.com/OpenFn/lightning/issues/4504)
4141
- AI assistant responses now stream in real-time
42+
- Add experimental global AI assistant option for users with experimental
43+
features enabled [#4532](https://github.com/OpenFn/lightning/issues/4532)
4244
[#4517](https://github.com/OpenFn/lightning/pull/4517)
4345
- Allow users to export all collection items as a JSON file.
4446
[#4527](https://github.com/OpenFn/lightning/issues/4527)

assets/js/collaborative-editor/components/AIAssistantPanel.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,20 @@ interface AIAssistantPanelProps {
6262
* AI assistant limit information
6363
*/
6464
aiLimit?: { allowed: boolean; message: string | null } | null;
65+
/** Show the experimental global assistant toggle */
66+
showGlobalAssistantOption?: boolean;
67+
/** Whether the global assistant checkbox is currently checked */
68+
isGlobalAssistantActive?: boolean;
69+
/** Callback when global assistant checkbox changes */
70+
onGlobalAssistantChange?: (active: boolean) => void;
6571
}
6672

6773
interface MessageOptions {
6874
attach_code?: boolean;
6975
attach_logs?: boolean;
7076
attach_io_data?: boolean;
7177
step_id?: string;
78+
use_global_assistant?: boolean;
7279
}
7380

7481
/**
@@ -103,6 +110,9 @@ export function AIAssistantPanel({
103110
onAcceptDisclaimer,
104111
connectionState = 'connected',
105112
aiLimit = null,
113+
showGlobalAssistantOption = false,
114+
isGlobalAssistantActive = false,
115+
onGlobalAssistantChange,
106116
}: AIAssistantPanelProps) {
107117
const [view, setView] = useState<'chat' | 'sessions'>(
108118
sessionId ? 'chat' : 'sessions'
@@ -267,12 +277,18 @@ export function AIAssistantPanel({
267277
<span
268278
className={cn(
269279
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
270-
page === 'job_code'
271-
? 'bg-blue-100 text-blue-800'
272-
: 'bg-purple-100 text-purple-800'
280+
isGlobalAssistantActive
281+
? 'bg-amber-100 text-amber-800'
282+
: page === 'job_code'
283+
? 'bg-blue-100 text-blue-800'
284+
: 'bg-purple-100 text-purple-800'
273285
)}
274286
>
275-
{page === 'job_code' ? 'Job' : 'Workflow'}
287+
{isGlobalAssistantActive
288+
? 'Global (experimental)'
289+
: page === 'job_code'
290+
? 'Job'
291+
: 'Workflow'}
276292
</span>
277293
)}
278294
</div>
@@ -433,6 +449,8 @@ export function AIAssistantPanel({
433449
(!hasSessionContext || !hasCompletedSessionLoad))
434450
}
435451
showJobControls={page === 'job_code'}
452+
showGlobalAssistantOption={showGlobalAssistantOption}
453+
onGlobalAssistantChange={onGlobalAssistantChange}
436454
storageKey={storageKey}
437455
enableAutoFocus={
438456
isOpen &&

assets/js/collaborative-editor/components/AIAssistantPanelWrapper.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { useAIWorkflowApplications } from '../hooks/useAIWorkflowApplications';
2727
import { useAutoPreview } from '../hooks/useAutoPreview';
2828
import { useResizablePanel } from '../hooks/useResizablePanel';
2929
import {
30+
useExperimentalFeaturesEnabled,
3031
useHasReadAIDisclaimer,
3132
useIsNewWorkflow,
3233
useLimits,
@@ -133,6 +134,8 @@ export function AIAssistantPanelWrapper({
133134
const connectionState = useAIConnectionState();
134135
const sessionContextLoaded = useSessionContextLoaded();
135136
const hasReadDisclaimer = useHasReadAIDisclaimer();
137+
const experimentalFeaturesEnabled = useExperimentalFeaturesEnabled();
138+
const [isGlobalAssistantActive, setIsGlobalAssistantActive] = useState(false);
136139
const markAIDisclaimerRead = useMarkAIDisclaimerRead();
137140
const workflowTemplateContext = useAIWorkflowTemplateContext();
138141
const project = useProject();
@@ -309,6 +312,7 @@ export function AIAssistantPanelWrapper({
309312
attach_logs?: boolean;
310313
attach_io_data?: boolean;
311314
step_id?: string;
315+
use_global_assistant?: boolean;
312316
}
313317
) => {
314318
const currentState = aiStore.getSnapshot();
@@ -355,10 +359,16 @@ export function AIAssistantPanelWrapper({
355359
...(messageOptions?.attach_logs && { attach_logs: true }),
356360
...(messageOptions?.attach_io_data && { attach_io_data: true }),
357361
...(messageOptions?.step_id && { step_id: messageOptions.step_id }),
362+
...(messageOptions?.use_global_assistant && {
363+
use_global_assistant: true,
364+
}),
358365
};
359366

360-
// Add workflow YAML if in workflow mode
361-
if (page === 'workflow_template') {
367+
// Add workflow YAML if in workflow mode or global assistant
368+
if (
369+
page === 'workflow_template' ||
370+
messageOptions?.use_global_assistant
371+
) {
362372
const workflowData = prepareWorkflowForSerialization(
363373
workflow,
364374
jobs,
@@ -372,6 +382,18 @@ export function AIAssistantPanelWrapper({
372382
finalContext = { ...finalContext, code: workflowYAML };
373383
}
374384
}
385+
386+
// Derive page for global assistant routing
387+
if (messageOptions?.use_global_assistant) {
388+
const jobName = (context as JobCodeContext)?.job_name;
389+
const workflowName = workflow?.name || 'workflow';
390+
finalContext = {
391+
...finalContext,
392+
page: jobName
393+
? `workflows/${workflowName}/${jobName}`
394+
: `workflows/${workflowName}`,
395+
};
396+
}
375397
}
376398

377399
// Initialize store with context including content
@@ -398,12 +420,17 @@ export function AIAssistantPanelWrapper({
398420
attach_io_data?: boolean;
399421
step_id?: string;
400422
code?: string;
423+
use_global_assistant?: boolean;
424+
page?: string;
401425
}
402426
| undefined = {
403427
...messageOptions, // Include attach_code, attach_logs, attach_io_data, step_id
404428
};
405429

406-
if (aiMode?.page === 'workflow_template') {
430+
if (
431+
aiMode?.page === 'workflow_template' ||
432+
messageOptions?.use_global_assistant
433+
) {
407434
const workflowData = prepareWorkflowForSerialization(
408435
workflow,
409436
jobs,
@@ -418,6 +445,15 @@ export function AIAssistantPanelWrapper({
418445
if (workflowYAML) {
419446
options = { ...options, code: workflowYAML };
420447
}
448+
449+
// Derive page for global assistant routing
450+
if (messageOptions?.use_global_assistant) {
451+
const jobName = (aiMode?.context as JobCodeContext)?.job_name;
452+
const workflowName = workflow?.name || 'workflow';
453+
options.page = jobName
454+
? `workflows/${workflowName}/${jobName}`
455+
: `workflows/${workflowName}`;
456+
}
421457
} else {
422458
// important: determines what ai to be used
423459
options = { ...aiMode?.context, ...options };
@@ -449,6 +485,10 @@ export function AIAssistantPanelWrapper({
449485
[aiStore, retryMessageViaChannel]
450486
);
451487

488+
const handleGlobalAssistantChange = useCallback((active: boolean) => {
489+
setIsGlobalAssistantActive(active);
490+
}, []);
491+
452492
const handleMarkDisclaimerRead = useCallback(() => {
453493
// Persist to backend via workflow channel and update local state
454494
markAIDisclaimerRead();
@@ -642,6 +682,9 @@ export function AIAssistantPanelWrapper({
642682
showDisclaimer={sessionContextLoaded && !hasReadDisclaimer}
643683
onAcceptDisclaimer={handleMarkDisclaimerRead}
644684
aiLimit={limits.ai_assistant ?? null}
685+
showGlobalAssistantOption={experimentalFeaturesEnabled}
686+
isGlobalAssistantActive={isGlobalAssistantActive}
687+
onGlobalAssistantChange={handleGlobalAssistantChange}
645688
>
646689
<MessageList
647690
messages={messages}

assets/js/collaborative-editor/components/ChatInput.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ interface ChatInputProps {
1313
isDisabled?: boolean | undefined;
1414
/** Show job-specific controls (attach code, attach logs) */
1515
showJobControls?: boolean | undefined;
16+
/** Show the experimental global assistant toggle */
17+
showGlobalAssistantOption?: boolean | undefined;
18+
/** Callback when global assistant checkbox changes */
19+
onGlobalAssistantChange?: ((active: boolean) => void) | undefined;
1620
/** Storage key for persisting checkbox preferences */
1721
storageKey?: string | undefined;
1822
/** Enable automatic focus management for the input */
@@ -36,6 +40,7 @@ interface MessageOptions {
3640
attach_logs?: boolean;
3741
attach_io_data?: boolean;
3842
step_id?: string;
43+
use_global_assistant?: boolean;
3944
}
4045

4146
const MIN_TEXTAREA_HEIGHT = 52;
@@ -46,6 +51,8 @@ export function ChatInput({
4651
isLoading = false,
4752
isDisabled = false,
4853
showJobControls = false,
54+
showGlobalAssistantOption = false,
55+
onGlobalAssistantChange,
4956
storageKey,
5057
enableAutoFocus = false,
5158
focusTrigger,
@@ -96,6 +103,17 @@ export function ChatInput({
96103
}
97104
});
98105

106+
const [useGlobalAssistant, setUseGlobalAssistant] = useState(() => {
107+
if (!storageKey) return false;
108+
try {
109+
return (
110+
localStorage.getItem(`${storageKey}:use-global-assistant`) === 'true'
111+
);
112+
} catch {
113+
return false;
114+
}
115+
});
116+
99117
const textareaRef = useRef<HTMLTextAreaElement>(null);
100118
const isLoadingFromStorageRef = useRef(false);
101119

@@ -143,6 +161,14 @@ export function ChatInput({
143161
// Ignore localStorage errors
144162
}
145163

164+
try {
165+
const globalValue =
166+
localStorage.getItem(`${storageKey}:use-global-assistant`) === 'true';
167+
setUseGlobalAssistant(globalValue);
168+
} catch {
169+
// Ignore localStorage errors
170+
}
171+
146172
setTimeout(() => {
147173
isLoadingFromStorageRef.current = false;
148174
}, 0);
@@ -181,6 +207,23 @@ export function ChatInput({
181207
}
182208
}, [attachIoData, storageKey]);
183209

210+
useEffect(() => {
211+
if (!storageKey) return;
212+
if (isLoadingFromStorageRef.current) return;
213+
try {
214+
localStorage.setItem(
215+
`${storageKey}:use-global-assistant`,
216+
String(useGlobalAssistant)
217+
);
218+
} catch {
219+
// Ignore localStorage errors
220+
}
221+
}, [useGlobalAssistant, storageKey]);
222+
223+
useEffect(() => {
224+
onGlobalAssistantChange?.(useGlobalAssistant);
225+
}, [useGlobalAssistant, onGlobalAssistantChange]);
226+
184227
useEffect(() => {
185228
if (enableAutoFocus && textareaRef.current) {
186229
const timeoutId = setTimeout(() => {
@@ -216,6 +259,10 @@ export function ChatInput({
216259
}
217260
}
218261

262+
if (showGlobalAssistantOption && useGlobalAssistant) {
263+
options.use_global_assistant = true;
264+
}
265+
219266
onSendMessage?.(input.trim(), options);
220267
setInput('');
221268
};
@@ -422,6 +469,31 @@ export function ChatInput({
422469
</span>
423470
</div>
424471
)}
472+
473+
{showGlobalAssistantOption && (
474+
<Tooltip
475+
content="Route messages to the experimental global assistant"
476+
side="top"
477+
>
478+
<label className="flex items-center gap-1.5 group cursor-pointer">
479+
<input
480+
type="checkbox"
481+
checked={useGlobalAssistant}
482+
onChange={e =>
483+
setUseGlobalAssistant(e.target.checked)
484+
}
485+
className={cn(
486+
'w-3.5 h-3.5 rounded border-amber-400 text-amber-600',
487+
'focus:ring-amber-500 focus:ring-offset-0',
488+
'cursor-pointer'
489+
)}
490+
/>
491+
<span className="text-[11px] font-medium text-amber-600 group-hover:text-amber-700">
492+
Global assistant (experimental)
493+
</span>
494+
</label>
495+
</Tooltip>
496+
)}
425497
</div>
426498

427499
<button

assets/js/collaborative-editor/hooks/useSessionContext.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,22 @@ export const useHasReadAIDisclaimer = (): boolean => {
305305
);
306306
};
307307

308+
/**
309+
* Hook to check if the user has experimental features enabled
310+
*/
311+
export const useExperimentalFeaturesEnabled = (): boolean => {
312+
const sessionContextStore = useSessionContextStore();
313+
314+
const selectExperimentalFeaturesEnabled = sessionContextStore.withSelector(
315+
state => state.experimentalFeaturesEnabled
316+
);
317+
318+
return useSyncExternalStore(
319+
sessionContextStore.subscribe,
320+
selectExperimentalFeaturesEnabled
321+
);
322+
};
323+
308324
/**
309325
* Hook to get setHasReadAIDisclaimer action
310326
* Returns function to update AI disclaimer read status (local state only)

assets/js/collaborative-editor/lib/AIChannelRegistry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,14 @@ export class AIChannelRegistry {
915915
}
916916
}
917917

918+
// Global assistant flags (applicable to both session types)
919+
if ('use_global_assistant' in context && context.use_global_assistant) {
920+
params['use_global_assistant'] = true;
921+
}
922+
if ('page' in context && context.page) {
923+
params['page'] = context.page as string;
924+
}
925+
918926
return params;
919927
}
920928
}

assets/js/collaborative-editor/stores/createSessionContextStore.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export const createSessionContextStore = (
122122
versionsError: null,
123123
workflow_template: null,
124124
hasReadAIDisclaimer: false,
125+
experimentalFeaturesEnabled: false,
125126
limits: {},
126127
isNewWorkflow,
127128
isLoading: false,
@@ -189,6 +190,8 @@ export const createSessionContextStore = (
189190
draft.webhookAuthMethods = sessionContext.webhook_auth_methods;
190191
draft.workflow_template = sessionContext.workflow_template;
191192
draft.hasReadAIDisclaimer = sessionContext.has_read_ai_disclaimer;
193+
draft.experimentalFeaturesEnabled =
194+
sessionContext.experimental_features_enabled;
192195
draft.limits = sessionContext.limits;
193196
draft.isLoading = false;
194197
draft.error = null;

assets/js/collaborative-editor/types/ai-assistant.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ export interface MessageOptions {
216216

217217
code?: string;
218218
errors?: string;
219+
220+
use_global_assistant?: boolean;
221+
page?: string;
219222
}
220223

221224
/**

assets/js/collaborative-editor/types/sessionContext.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export const SessionContextResponseSchema = z.object({
104104
webhook_auth_methods: z.array(WebhookAuthMethodSchema),
105105
workflow_template: WorkflowTemplateSchema.nullable(),
106106
has_read_ai_disclaimer: z.boolean(),
107+
experimental_features_enabled: z.boolean().optional().default(false),
107108
limits: LimitsSchema.optional(),
108109
workflow: BaseWorkflowSchema.optional(),
109110
});
@@ -127,6 +128,7 @@ export interface SessionContextState {
127128
versionsError: string | null;
128129
workflow_template: WorkflowTemplate | null;
129130
hasReadAIDisclaimer: boolean;
131+
experimentalFeaturesEnabled: boolean;
130132
limits: Limits;
131133
isNewWorkflow: boolean;
132134
isLoading: boolean;

0 commit comments

Comments
 (0)