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
11 changes: 4 additions & 7 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,12 @@ import ProtectedRoutes from './Pages/components/ProtectedRoutes'
import ProfilePage from './Pages/profile/Profile'
import AboutPage from './Pages/AboutUs'
import SettingsPage from './Pages/Settings/Settings'

// big objectives
// ADD THE ABILITY TO SEND USER WORKFLOWS TO ADD ON IT/FIX IT - HARD - BACKEND HEAVY
// ADD THE ABILITY TO CREATE CUSTOM NODES - VERY HARD - F/B HEAVY ON BOTH
// ADD THE ABILITY TO SAVE CREDENTIALS OR FIGURE OUT A WAY TO DO IT AUTOMATICALLY - HARD F/B HEAVY ON BOTH

// white screen appearing on reload then page appears
import { useBackgroundStreamNotifications } from './Pages/copilot/hooks/ui/useBackgroundStreamNotifications.hook'

function App() {
// listens for background stream completions globally
useBackgroundStreamNotifications();

return (
<BrowserRouter>
<Routes>
Expand Down
28 changes: 20 additions & 8 deletions client/src/Pages/Settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,35 @@ const SettingsPage = () => {
const hasPassword = data?.normalAccount;
const hasGoogle = data?.googleAccount;


const handleSetPassword = () => {
if (newPassword !== confirmPassword) return;
setPasswordMutation.mutate(
{
current_password: hasPassword ? currentPassword : undefined,
new_password: newPassword,
new_password_confirmation: confirmPassword,
},
{
onSuccess: () => {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
},
}
);
}

setPasswordMutation.mutate({
current_password: hasPassword ? currentPassword : undefined,
new_password: newPassword,
new_password_confirmation: confirmPassword,
});
};

const handleLinkN8n = () => {
if (!n8nBaseUrl || !n8nApiKey) return;

linkN8nMutation.mutate({
base_url: n8nBaseUrl,
api_key: n8nApiKey,
}, {
onSuccess: () => {
setN8nBaseUrl("");
setN8nApiKey("");
}
});
};

Expand Down
30 changes: 25 additions & 5 deletions client/src/Pages/copilot/Copilot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import {
} from "./types";

import { useCopilotChatController } from "./hooks/ui/useCopilotChat.hook";
import { useCopilotStream } from "./hooks/ui/useCopilotStream.hook";
import { useCopilotFeedback } from "./hooks/ui/useCopilotFeedback.hook";
import { applyTrace } from "./utils/traceAdapter";
import { buildWorkflowFile, commitHistory, finalizeAssistantMessage } from "./utils/onComplete";
import { useCopilotHistoryController } from "./hooks/ui/useCopilotHistoryController.hook";
import { HistoryPanel } from "./components/HistoryPanel/HistoryPanel";
import { useAuth } from "../../context/useAuth";
import { useBackgroundStreamNotifications } from "./hooks/ui/useBackgroundStreamNotifications.hook";
import { useCopilotStream } from "./hooks/ui/useCopilotStream.hook";

export const Copilot =() => {
const { user } = useAuth();
Expand All @@ -28,6 +29,7 @@ export const Copilot =() => {
const [question, setQuestion] = useState("");
const [stage, setStage] = useState<GenerationStage>("idle");
const [currentHistoryId, setCurrentHistoryId] = useState<number | null>(null);
const [isUserOnPage, setIsUserOnPage] = useState(true);

const activeKey = currentHistoryId ?? "new";
const textareaRef = useRef<HTMLTextAreaElement>(null);
Expand All @@ -40,6 +42,8 @@ export const Copilot =() => {
{ new: [] }
);

// Global background stream notifications
useBackgroundStreamNotifications();

// streaming hook
const { run , cancel , runId} = useCopilotStream({
Expand Down Expand Up @@ -95,6 +99,20 @@ export const Copilot =() => {
setActiveGenerationKey,
});

// Track if user is on the page for feedback
useEffect(() => {
const handleFocus = () => setIsUserOnPage(true);
const handleBlur = () => setIsUserOnPage(false);

window.addEventListener("focus", handleFocus);
window.addEventListener("blur", handleBlur);

return () => {
window.removeEventListener("focus", handleFocus);
window.removeEventListener("blur", handleBlur);
};
}, []);

// scroll effect
useEffect(() => {
chatRef.current?.scrollTo({
Expand Down Expand Up @@ -137,7 +155,11 @@ export const Copilot =() => {
setCurrentHistoryId(newHistoryId);
setStage("done");
setActiveGenerationKey(null);
openFeedback(question, answer);

// Only show feedback toast if user is still on the page
if (isUserOnPage) {
openFeedback(question, answer);
}
};

const handleStreamError = () => {
Expand All @@ -148,8 +170,6 @@ export const Copilot =() => {
[key]: [],
}));

console.log("here");

setMessageStore(prev => {
const msgs = prev[key] ?? [];

Expand Down Expand Up @@ -225,7 +245,7 @@ export const Copilot =() => {
</div>
</div>

{feedback && (
{feedback && isUserOnPage && (
<FeedbackToast
feedback={feedback}
onYes={confirmYes}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useEffect } from "react";
import { useToast } from "../../../../context/toastContext";
import { backgroundStreamService } from "../../services/backgroundStreamService";

/**
* Global hook that listens for background stream completions
* and shows toasts when workflows are ready.
* Should be mounted at the app root level.
*/
export function useBackgroundStreamNotifications() {
const { showToast } = useToast();

useEffect(() => {
const checkStreams = setInterval(() => {
const activeStreams = backgroundStreamService.getActiveStreams();

// check if any streams are in "done" stage and show notification
activeStreams.forEach((stream) => {
if (stream.stage === "done") {
showToast("Your workflow is ready!", "success");
backgroundStreamService.stopStream(stream.key);
}
});
}, 1000);

return () => clearInterval(checkStreams);
}, [showToast]);
}
68 changes: 37 additions & 31 deletions client/src/Pages/copilot/hooks/ui/useCopilotStream.hook.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useRef } from "react";
import type { GenerationStage, ChatMessage } from "../../types";
import { streamCopilotQuestion } from "../data/streamResponse";
import { useToast } from "../../../../context/toastContext";
import { backgroundStreamService } from "../../services/backgroundStreamService";

export function useCopilotStream({
onStage,
Expand All @@ -17,52 +17,58 @@ export function useCopilotStream({
onError: () => void;
}) {
const { showToast } = useToast();
const streamRef = useRef<EventSource | null>(null); // SSE connection
const runIdRef = useRef(0);
const currentKeyRef = useRef<number | "new">("new");

const cancel = () => {
streamRef.current?.close();
streamRef.current = null;
};

const cancel = () => {
backgroundStreamService.stopStream(currentKeyRef.current);
};

const run = (
messages: ChatMessage[],
historyId: number | null,// active key
historyId: number | null,
key: number | "new" = "new",
userId: number
) => {
runIdRef.current += 1;
const id =runIdRef.current;
// close any previous SSE connection
streamRef.current?.close();
const id = runIdRef.current;
currentKeyRef.current = key;

// immediately enqueue "analyzing" stage
onStage("analyzing");
// open new SSE connection
streamRef.current = streamCopilotQuestion(
userId,

// Use background stream service - stream continues even after navigation
backgroundStreamService.startStream(
key,
messages,
showToast,
historyId,
(stage) => {
onStage(stage as GenerationStage);
onProgress(key, stage as GenerationStage);
},
(trace) => {
if (id !== runIdRef.current) return;
onTrace(key, trace);
},
(answer, historyId) => {
if (id !== runIdRef.current) return;
onComplete(answer, historyId);
},
() =>{
if (id !== runIdRef.current) return;
onError();
userId,
(message: string, type?: string) => showToast(message, type as any),
{
onStage: (_streamKey, stage) => {
if (id !== runIdRef.current) return;
onStage(stage);
},
onProgress: (_streamKey, stage) => {
if (id !== runIdRef.current) return;
onProgress(_streamKey, stage);
},
onTrace: (_streamKey, trace) => {
if (id !== runIdRef.current) return;
onTrace(_streamKey, trace);
},
onComplete: (_streamKey, answer, historyId) => {
if (id !== runIdRef.current) return;
onComplete(answer, historyId);
},
onError: (_streamKey) => {
if (id !== runIdRef.current) return;
onError();
},
}
);
};

const runId = runIdRef.current;
return { run, cancel , runId};
return { run, cancel, runId };
}
122 changes: 122 additions & 0 deletions client/src/Pages/copilot/services/backgroundStreamService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { ChatMessage, GenerationStage } from "../types";
import { streamCopilotQuestion } from "../hooks/data/streamResponse";
import type { ToastType } from "../../components/toast/toast.types";

export type StreamKey = number | "new";

export interface StreamState {
key: StreamKey;
historyId: number | null;
messages: ChatMessage[];
userId: number;
stage: GenerationStage;
eventSource: EventSource | null;
startedAt: number;
}

export interface StreamListeners {
onStage?: (key: StreamKey, stage: GenerationStage) => void;
onProgress?: (key: StreamKey, stage: GenerationStage) => void;
onTrace?: (key: StreamKey, trace: any) => void;
onComplete?: (key: StreamKey, answer: any, historyId: number) => void;
onError?: (key: StreamKey) => void;
showToast?:(message: string, type?: ToastType) => void;
}

class BackgroundStreamService {
private streams = new Map<StreamKey, StreamState>();
private listeners = new Map<string, StreamListeners>();

startStream(
key: StreamKey,
messages: ChatMessage[],
historyId: number | null,
userId: number,
showToast: (message: string, type?: string) => void,
onListeners: StreamListeners
) {
this.stopStream(key);

const streamState: StreamState = {
key,
historyId,
messages,
userId,
stage: "analyzing",
eventSource: null,
startedAt: Date.now(),
};

const listenerId = `stream_${key}`;
this.listeners.set(listenerId, onListeners);

const params = new URLSearchParams();
params.append("messages", JSON.stringify(messages));
params.append("userId", userId.toString());
if (historyId) params.append("history_id", historyId.toString());

const eventSource = streamCopilotQuestion(
userId,
messages,
showToast,
historyId,
(stage) => {
streamState.stage = stage as GenerationStage;
onListeners.onStage?.(key, stage as GenerationStage);
onListeners.onProgress?.(key, stage as GenerationStage);
},
(trace) => {
onListeners.onTrace?.(key, trace);
},
(answer, newHistoryId) => {
onListeners.onComplete?.(key, answer, newHistoryId);
this.streams.delete(key);
this.listeners.delete(listenerId);
},
() => {
onListeners.onError?.(key);
this.streams.delete(key);
this.listeners.delete(listenerId);
}
);

streamState.eventSource = eventSource;
this.streams.set(key, streamState);

return {
cancel: () => this.stopStream(key),
getStream: () => this.streams.get(key),
};
}

stopStream(key: StreamKey) {
const stream = this.streams.get(key);
if (stream?.eventSource) {
stream.eventSource.close();
}
this.streams.delete(key);
this.listeners.delete(`stream_${key}`);
}

getActiveStreams(): StreamState[] {
return Array.from(this.streams.values());
}

isStreamActive(key: StreamKey): boolean {
return this.streams.has(key);
}

getStream(key: StreamKey): StreamState | undefined {
return this.streams.get(key);
}

stopAllStreams() {
this.streams.forEach((stream) => {
stream.eventSource?.close();
});
this.streams.clear();
this.listeners.clear();
}
}

export const backgroundStreamService = new BackgroundStreamService();
Loading