Skip to content
Open
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: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ and this project adheres to
advisories from the `mix deps.audit` ignore list.
[#4846](https://github.com/OpenFn/lightning/issues/4846)
- Bump worker to 1.27.0
- The global chat now starts streaming Apollo's response earlier, so users wait
less before seeing output. Lightning handles the several streaming event types
Apollo sends, including status updates.
[#4969](https://github.com/OpenFn/lightning/pull/4969)

### Fixed

Expand All @@ -52,7 +56,7 @@ and this project adheres to
code...") that Apollo streams _after_ the text answer while it generates code,
displayed below the answer in the same style as the initial "Thinking..."
indicator. Statuses are surfaced in whatever order Apollo sends them.
[#PR](https://github.com/OpenFn/lightning/pull/PR)
[#4833](https://github.com/OpenFn/lightning/pull/4833)

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
useAIStore,
useAIStreamingChanges,
useAIStreamingContent,
useAIStreamingSegments,
useAIStreamingStatus,
useAIWorkflowTemplateContext,
} from '../hooks/useAIAssistant';
Expand Down Expand Up @@ -142,6 +143,7 @@ export function AIAssistantPanelWrapper({
const isLoading = useAIIsLoading();
const streamingContent = useAIStreamingContent();
const streamingStatus = useAIStreamingStatus();
const streamingSegments = useAIStreamingSegments();
const streamingChanges = useAIStreamingChanges();
const sessionId = useAISessionId();
const sessionType = useAISessionType();
Expand Down Expand Up @@ -632,19 +634,26 @@ export function AIAssistantPanelWrapper({
const appliedViaStreamingRef = useRef(false);
useEffect(() => {
if (!streamingChanges || !canApplyChanges) return;
// Avoid re-applying the same streaming changes object
// Avoid re-applying the same streaming changes object. The ref is only
// set once a change is actually handled, so a change that couldn't apply
// stays eligible if the page switches mid-stream.
if (appliedStreamingChangesRef.current === streamingChanges) return;
appliedStreamingChangesRef.current = streamingChanges;

if (aiMode?.page === 'workflow_template' && 'yaml' in streamingChanges) {
// Workflow YAML applies to the shared Y.Doc, so it is page-independent:
// global chat streams it from the job code view too, and the diagram
// must be up to date whenever the user navigates there.
if ('yaml' in streamingChanges) {
const yaml = streamingChanges['yaml'] as string;
if (yaml) {
appliedStreamingChangesRef.current = streamingChanges;
appliedViaStreamingRef.current = true;
void handleApplyWorkflow(yaml, '__streaming__');
}
} else if (aiMode?.page === 'job_code' && 'code' in streamingChanges) {
// Job code previews open job-editor UI, so they stay page-gated.
const code = streamingChanges['code'] as string;
if (code) {
appliedStreamingChangesRef.current = streamingChanges;
appliedViaStreamingRef.current = true;
handlePreviewJobCode(code, '__streaming__');
}
Expand Down Expand Up @@ -772,6 +781,8 @@ export function AIAssistantPanelWrapper({
isWriteDisabled={isWriteDisabled}
streamingContent={streamingContent}
streamingStatus={streamingStatus}
streamingSegments={streamingSegments}
isGlobalAssistantActive={isGlobalAssistantActive}
/>
</AIAssistantPanel>
</div>
Expand Down
138 changes: 121 additions & 17 deletions assets/js/collaborative-editor/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import remarkGfm from 'remark-gfm';
import { useCopyToClipboard } from '#/collaborative-editor/hooks/useCopyToClipboard';
import { cn } from '#/utils/cn';

import type { Message } from '../types/ai-assistant';
import type { Message, ResponseSegment } from '../types/ai-assistant';

import { Tooltip } from '../../components/Tooltip';

Expand All @@ -26,6 +26,21 @@ const BouncingDots = () => (
</>
);

/**
* A single persistent status row in the woven timeline: a completed action
* ("Edited workflow structure"), italic gray with a small check. Transient
* thinking updates render separately (dots) from the scalar streamingStatus.
*/
const StatusSegmentRow = ({ content }: { content: string }) => (
<div className="flex items-center gap-2" data-testid="settled-status">
<span
className="hero-check-micro h-3.5 w-3.5 shrink-0 text-gray-400"
aria-hidden="true"
/>
<span className="text-xs text-gray-400 italic">{content}</span>
</div>
);

/**
* Custom code block component for react-markdown
* Renders code with COPY/ADD action buttons
Expand Down Expand Up @@ -159,6 +174,58 @@ const MarkdownContent = ({
);
};

/**
* Woven timeline of text and status segments (global assistant replies).
* Text segments render as markdown blocks; status segments as italic rows.
* Status segments are completed actions and always render settled (tick).
* The transient thinking indicator (dots) renders separately, from the
* scalar streamingStatus, below the timeline.
*/
const SegmentTimeline = ({
segments,
streaming = false,
showAddButtons = false,
isWriteDisabled = false,
}: {
segments: ResponseSegment[];
streaming?: boolean;
showAddButtons?: boolean;
isWriteDisabled?: boolean;
}) => (
<>
{segments.map((segment, index) => {
const isLast = index === segments.length - 1;

if (segment.type === 'status') {
// Statuses in the timeline are completed actions (Apollo's dedicated
// `status` event) — they settle with a tick immediately. In-progress
// "thinking" updates render separately from the scalar streamingStatus.
return (
<StatusSegmentRow
// Timeline is append-only, so index keys are stable
key={index}
content={segment.content}
/>
);
}

return (
<MarkdownContent
key={index}
content={
streaming && isLast
? segment.content.replace(/\n+$/, '')
: segment.content
}
showAddButtons={showAddButtons}
isWriteDisabled={isWriteDisabled}
className={PROSE_CLASSES}
/>
);
})}
</>
);

/**
* Copy text to clipboard using modern Clipboard API
*/
Expand Down Expand Up @@ -408,6 +475,14 @@ interface MessageListProps {
isWriteDisabled?: boolean;
streamingContent?: string | null;
streamingStatus?: string | null;
/** Woven text/status timeline built while a reply streams in */
streamingSegments?: ResponseSegment[] | null;
/**
* Whether the global assistant is active. Gates the woven streaming
* timeline — non-global streams keep the flat content + single scalar
* status row behavior.
*/
isGlobalAssistantActive?: boolean;
}

export function MessageList({
Expand All @@ -426,6 +501,8 @@ export function MessageList({
isWriteDisabled = false,
streamingContent,
streamingStatus,
streamingSegments,
isGlobalAssistantActive = false,
}: MessageListProps) {
const loadingRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -507,6 +584,20 @@ export function MessageList({

const isStreaming = (message: Message) => message.id === STREAMING_MESSAGE_ID;

// Woven text/status timeline to render instead of flat content, or null.
// - Completed messages: persisted `response_segments` (global replies).
// - Streaming placeholder: live `streamingSegments`, gated on the global
// assistant being active so non-global streams keep today's flat
// content + single scalar status row.
const timelineSegments = (message: Message): ResponseSegment[] | null => {
if (isStreaming(message)) {
return isGlobalAssistantActive && streamingSegments?.length
? streamingSegments
: null;
}
return message.response_segments?.length ? message.response_segments : null;
};

if (messages.length === 0) {
return (
<div
Expand Down Expand Up @@ -551,22 +642,35 @@ export function MessageList({
}
>
<div className="space-y-3">
<MarkdownContent
content={
isStreaming(message)
? message.content.replace(/\n+$/, '')
: message.content
}
showAddButtons={
!isStreaming(message) && showAddButtons && !message.code
}
isWriteDisabled={isWriteDisabled}
className={PROSE_CLASSES}
/>

{/* Status (e.g. "Generating code...") Apollo may stream
after the text answer, while we wait for code. Same
visual as the pre-text loading indicator. */}
{timelineSegments(message) ? (
<SegmentTimeline
segments={timelineSegments(message)!}
streaming={isStreaming(message)}
showAddButtons={
!isStreaming(message) && showAddButtons && !message.code
}
isWriteDisabled={isWriteDisabled}
/>
) : (
<MarkdownContent
content={
isStreaming(message)
? message.content.replace(/\n+$/, '')
: message.content
}
showAddButtons={
!isStreaming(message) && showAddButtons && !message.code
}
isWriteDisabled={isWriteDisabled}
className={PROSE_CLASSES}
/>
)}

{/* Transient thinking status (e.g. "Reviewing the
workflow...") — dots + italic, replaced by each new
thinking event and cleared when text or a persistent
status segment arrives. Renders below the woven
timeline and in the flat path alike. */}
{isStreaming(message) && streamingStatus && (
<div
className="flex items-center gap-2"
Expand Down
11 changes: 11 additions & 0 deletions assets/js/collaborative-editor/hooks/useAIAssistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ export const useAIStreamingContent = () => {
);
};

/**
* Get the woven text/status streaming timeline (global assistant streams)
*/
export const useAIStreamingSegments = () => {
const store = useAIStore();
return useSyncExternalStore(
store.subscribe,
store.withSelector(state => state.streamingSegments)
);
};

/**
* Get streaming changes (code edits or workflow YAML sent before text streams)
*/
Expand Down
15 changes: 10 additions & 5 deletions assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,16 @@ export function useAIWorkflowApplications({
async (yaml: string, messageId: string) => {
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;
// while a job is open (job_code mode). Mid-stream applies
// ('__streaming__') have no session message to look up, but a workflow
// YAML streamed while a job is open can only come from the global
// assistant, so they are trusted the same way. Non-global workflow
// chat keeps the workflow_template-only guard so its Apply stays a
// no-op when a job is open.
const isGlobal =
messageId === '__streaming__' ||
!!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 Down
Loading