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
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)
- AI assistant diff blocks show the step with its adaptor logo. Clicking it
opens the step and highlights the code the AI added.
- GitHub sync connections using the v2 format now install `@v2` of the OpenFn
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 @@ -55,6 +55,7 @@ import {
useSession,
} from '../hooks/useSession';
import {
useAISkills,
useIsNewWorkflow,
useLimits,
useProject,
Expand Down Expand Up @@ -188,6 +189,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 @@ -809,6 +811,7 @@ export function AIAssistantPanelWrapper({
connectionState={sessionId ? connectionState : 'connected'}
aiLimit={limits.ai_assistant ?? null}
switchToDraft={switchToDraft}
skills={skills}
>
<MessageList
messages={messages}
Expand Down Expand Up @@ -869,6 +872,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
Loading