Skip to content
Open
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ and this project adheres to

### Added

- Slash-command skills (`/design`, `/diagnose`, `/qa`) in the global AI
assistant, and the assistant's conversation history is now stored as Apollo
returns it. Needs the matching Apollo release first.
[#5236](https://github.com/OpenFn/lightning/pull/5236)
- The AI assistant now shows a warning banner above its chat input when the
current workflow is live.
[#5205](https://github.com/OpenFn/lightning/pull/5205)
Expand Down
3 changes: 3 additions & 0 deletions assets/js/collaborative-editor/CollaborativeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { StoreProvider } from './contexts/StoreProvider';
import { useActionLock } from './hooks/useActionLock';
import { useHistoryCommands } from './hooks/useHistory';
import {
useAISkills,
useIsNewWorkflow,
useLatestSnapshotLockVersion,
useLimits,
Expand Down Expand Up @@ -366,6 +367,7 @@ function LandingScreenWrapper({
const showLandingScreen = useShowLandingScreen();
const aiLimit = useLimits().ai_assistant;
const aiLimitReached = aiLimit != null && !aiLimit.allowed;
const skills = useAISkills();
const {
openYAMLImportModal,
openTemplateBrowserModal,
Expand Down Expand Up @@ -399,6 +401,7 @@ function LandingScreenWrapper({
isBuildingFromScratch={isBuildingFromScratch}
onBrowseTemplates={openTemplateBrowserModal}
onImportYAML={openYAMLImportModal}
skills={skills}
/>
<YAMLImportModal />
<TemplateBrowserModalWrapper />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useSelectedRunId } from '../hooks/useHistory';
import { useContentLocked, useIsNewWorkflow } from '../hooks/useSessionContext';
import { describeLifecycleError } from '../lib/errors';
import { notifications } from '../lib/notifications';
import type { AISkill } from '../types/sessionContext';

import { AlertDialog } from './AlertDialog';
import { ChatInput } from './ChatInput';
Expand Down Expand Up @@ -61,6 +62,8 @@ interface AIAssistantPanelProps {
* Switch the current workflow to draft, for the live workflow notice
*/
switchToDraft?: () => Promise<unknown>;
/** Skills a leading slash command can invoke */
skills?: AISkill[];
}

interface MessageOptions {
Expand Down Expand Up @@ -100,6 +103,7 @@ export function AIAssistantPanel({
connectionState = 'connected',
aiLimit = null,
switchToDraft = () => Promise.resolve(undefined),
skills,
}: AIAssistantPanelProps) {
const [view, setView] = useState<'chat' | 'sessions'>(
sessionId ? 'chat' : 'sessions'
Expand Down Expand Up @@ -477,6 +481,7 @@ export function AIAssistantPanel({
placeholder={placeholderText}
disabledMessage={disabledMessage}
selectedRunId={selectedRunId}
skills={skills}
/>

{/* About AI Assistant Modal */}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

const INITIAL_LOADING_STATUSES = [
'Thinking about the question...',
'Thinking...',
'Working on it...',
'Processing your request...',
'Examining your question...',
Expand Down Expand Up @@ -54,6 +54,7 @@ import {
useSession,
} from '../hooks/useSession';
import {
useAISkills,
useIsNewWorkflow,
useLimits,
useProject,
Expand Down Expand Up @@ -185,6 +186,7 @@ export function AIAssistantPanelWrapper({
const user = useUser();
const workflow = useWorkflowState(state => state.workflow);
const limits = useLimits();
const skills = useAISkills();

// AI can apply changes if: not readonly OR is a new workflow (being created)
const canApplyChanges = !isReadOnly || isNewWorkflow;
Expand Down Expand Up @@ -802,6 +804,7 @@ export function AIAssistantPanelWrapper({
connectionState={sessionId ? connectionState : 'connected'}
aiLimit={limits.ai_assistant ?? null}
switchToDraft={switchToDraft}
skills={skills}
>
<MessageList
messages={messages}
Expand Down Expand Up @@ -862,6 +865,7 @@ export function AIAssistantPanelWrapper({
onOpenStep={handleOpenStep}
canOpenStep={canOpenStep}
currentUserId={user?.id}
skills={skills}
failedApplyMessageIds={failedApplyMessageIds}
onUndoChanges={requestUndoChanges}
undoneMessageId={undoneMessageId}
Expand Down
96 changes: 60 additions & 36 deletions assets/js/collaborative-editor/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import { useEffect, useRef, useState } from 'react';
import { cn } from '#/utils/cn';

import { Tooltip } from '../../components/Tooltip';
import { useSlashCommand } from '../hooks/useSlashCommand';
import type { AISkill } from '../types/sessionContext';

import { AIDisclaimerFooter } from './AIDisclaimerFooter';
import { SkillCommand } from './SkillCommand';
import { SkillMenu } from './SkillMenu';

interface ChatInputProps {
onSendMessage?:
Expand All @@ -25,6 +29,8 @@ interface ChatInputProps {
disabledMessage?: string | undefined;
/** The run both attachments are scoped to, and what gates them */
selectedRunId?: string | null;
/** Skills a leading slash command can invoke */
skills?: AISkill[] | undefined;
}

interface MessageOptions {
Expand Down Expand Up @@ -72,10 +78,15 @@ export function ChatInput({
placeholder = 'Ask me anything...',
disabledMessage,
selectedRunId,
skills = [],
}: ChatInputProps) {
const [input, setInput] = useState('');
const tooLong = input.length > MAX_MESSAGE_LENGTH;
const showCount = input.length >= COUNT_FROM;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const slash = useSlashCommand(skills, textareaRef);
const { input, skill, content } = slash;

const tooLong = content.length > MAX_MESSAGE_LENGTH;
const showCount = content.length >= COUNT_FROM;
const canSend = !!content && !isLoading && !isDisabled && !tooLong;

const [attachLogs, setAttachLogs] = useState(() => {
if (!storageKey) {
Expand Down Expand Up @@ -103,7 +114,6 @@ export function ChatInput({
}
});

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

const attachmentState: Record<AttachmentKey, boolean> = {
Expand Down Expand Up @@ -204,7 +214,7 @@ export function ChatInput({

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading || isDisabled || tooLong) return;
if (!canSend) return;

const options: MessageOptions = {};
// The run rides along so what we promise to attach and what the backend
Expand All @@ -215,11 +225,13 @@ export function ChatInput({
options.follow_run_id = selectedRunId;
}

onSendMessage?.(input.trim(), options);
setInput('');
onSendMessage?.(content, options);
slash.reset();
};

const handleKeyDown = (e: React.KeyboardEvent) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (slash.handleKeyDown(e)) return;

if (
e.key === 'Enter' &&
!e.shiftKey &&
Expand All @@ -241,11 +253,12 @@ export function ChatInput({
side="top"
>
<div className="relative">
<SkillMenu {...slash.menuProps} />
<div
className={cn(
'rounded-xl border-2 transition-all duration-200',
'bg-white',
input.trim()
content
? 'border-primary-300'
: 'border-gray-200 hover:border-gray-300'
)}
Expand Down Expand Up @@ -284,29 +297,42 @@ export function ChatInput({
</div>
)}

<textarea
ref={textareaRef}
data-testid="chat-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isLoading || isDisabled}
rows={1}
className={cn(
'block w-full px-4 py-3 bg-transparent resize-none',
'text-[15px] text-gray-900 placeholder:text-gray-400',
'border-0 outline-none focus:outline-none focus:ring-0',
'disabled:text-gray-400 disabled:cursor-not-allowed'
<div className="relative overflow-hidden">
{skill && (
<span
ref={slash.commandRef}
className="pointer-events-none absolute left-4 top-3 text-[15px]"
>
<SkillCommand skill={skill} withTooltip={false} />
</span>
)}
style={{
height: `${MIN_TEXTAREA_HEIGHT}px`,
minHeight: `${MIN_TEXTAREA_HEIGHT}px`,
maxHeight: `${MAX_TEXTAREA_HEIGHT}px`,
overflow: 'hidden',
overflowY: 'auto',
}}
/>
<textarea
ref={textareaRef}
data-testid="chat-input"
value={input}
onChange={e => slash.handleChange(e.target.value)}
onKeyDown={handleKeyDown}
onScroll={slash.onScroll}
placeholder={skill ? 'Add details (optional)' : placeholder}
disabled={isLoading || isDisabled}
rows={1}
{...slash.comboboxProps}
className={cn(
'block w-full px-4 py-3 bg-transparent resize-none',
'text-[15px] text-gray-900 placeholder:text-gray-400',
'border-0 outline-none focus:outline-none focus:ring-0',
'disabled:text-gray-400 disabled:cursor-not-allowed'
)}
style={{
textIndent: slash.textIndent,
height: `${MIN_TEXTAREA_HEIGHT}px`,
minHeight: `${MIN_TEXTAREA_HEIGHT}px`,
maxHeight: `${MAX_TEXTAREA_HEIGHT}px`,
overflow: 'hidden',
overflowY: 'auto',
}}
/>
</div>

<div className="flex items-center justify-between gap-3 px-3 pb-2">
<div className="min-w-0">
Expand All @@ -320,7 +346,7 @@ export function ChatInput({
tooLong ? 'text-red-600' : 'text-gray-400'
)}
>
{input.length.toLocaleString()} /{' '}
{content.length.toLocaleString()} /{' '}
{MAX_MESSAGE_LENGTH.toLocaleString()}
{tooLong ? ' — too long to send' : null}
</span>
Expand All @@ -331,15 +357,13 @@ export function ChatInput({
<button
type="submit"
data-testid="send-message-button"
disabled={
!input.trim() || isLoading || isDisabled || tooLong
}
disabled={!canSend}
className={cn(
'inline-flex items-center justify-center',
'h-7 w-7 rounded-lg',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
input.trim() && !isLoading && !isDisabled && !tooLong
canSend
? 'bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
)}
Expand Down
Loading