feat(FN-1465): merge fusion/fn-1465
This commit is contained in:
@@ -48,7 +48,7 @@ function deriveExecutorState(
|
||||
/**
|
||||
* Derive statistics from the task list.
|
||||
*/
|
||||
function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number): Pick<
|
||||
function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number, lastFetchTimeMs?: number): Pick<
|
||||
ExecutorStats,
|
||||
"runningTaskCount" | "blockedTaskCount" | "stuckTaskCount" | "queuedTaskCount" | "inReviewCount"
|
||||
> {
|
||||
@@ -62,7 +62,7 @@ function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number): Pick<
|
||||
switch (task.column) {
|
||||
case "in-progress":
|
||||
runningTaskCount++;
|
||||
if (isTaskStuck(task, taskStuckTimeoutMs)) {
|
||||
if (isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs)) {
|
||||
stuckTaskCount++;
|
||||
}
|
||||
break;
|
||||
@@ -101,7 +101,7 @@ function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number): Pick<
|
||||
* - Derives executorState from globalPause and enginePaused flags
|
||||
* - Returns ExecutorStats object with reactive updates
|
||||
*/
|
||||
export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTimeoutMs?: number): UseExecutorStatsResult {
|
||||
export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTimeoutMs?: number, lastFetchTimeMs?: number): UseExecutorStatsResult {
|
||||
|
||||
const [apiData, setApiData] = useState<{
|
||||
globalPause: boolean;
|
||||
@@ -173,7 +173,7 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
|
||||
}, [refresh]);
|
||||
|
||||
// Derive stats from tasks and API data
|
||||
const taskStats = deriveStatsFromTasks(tasks, taskStuckTimeoutMs);
|
||||
const taskStats = deriveStatsFromTasks(tasks, taskStuckTimeoutMs, lastFetchTimeMs);
|
||||
const executorState = deriveExecutorState(
|
||||
apiData.globalPause,
|
||||
apiData.enginePaused,
|
||||
|
||||
238
packages/dashboard/app/hooks/useQuickChat.ts
Normal file
238
packages/dashboard/app/hooks/useQuickChat.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ChatSession } from "@fusion/core";
|
||||
import {
|
||||
fetchChatSessions,
|
||||
createChatSession,
|
||||
fetchChatMessages,
|
||||
streamChatResponse,
|
||||
} from "../api";
|
||||
|
||||
export interface ChatMessageInfo {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
thinkingOutput?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UseQuickChatReturn {
|
||||
// Session state
|
||||
activeSession: ChatSession | null;
|
||||
sessionsLoading: boolean;
|
||||
|
||||
// Message state
|
||||
messages: ChatMessageInfo[];
|
||||
messagesLoading: boolean;
|
||||
isStreaming: boolean;
|
||||
streamingText: string;
|
||||
streamingThinking: string;
|
||||
|
||||
// Operations
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
switchSession: (agentId: string) => Promise<void>;
|
||||
loadMessages: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the QuickChatFAB component.
|
||||
* Provides chat session management and SSE streaming for real-time AI responses.
|
||||
*/
|
||||
export function useQuickChat(
|
||||
projectId?: string,
|
||||
addToast?: (msg: string, type?: "success" | "error") => void,
|
||||
): UseQuickChatReturn {
|
||||
// Session state
|
||||
const [activeSession, setActiveSession] = useState<ChatSession | null>(null);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||
|
||||
// Message state
|
||||
const [messages, setMessages] = useState<ChatMessageInfo[]>([]);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingText, setStreamingText] = useState("");
|
||||
const [streamingThinking, setStreamingThinking] = useState("");
|
||||
|
||||
// Stream connection ref for cleanup
|
||||
const streamRef = useRef<{ close: () => void } | null>(null);
|
||||
|
||||
// Track the current selected agent ID for session management
|
||||
const currentAgentIdRef = useRef<string>("");
|
||||
|
||||
// Fetch existing sessions and find/create one for the given agent
|
||||
const initializeSession = useCallback(
|
||||
async (agentId: string) => {
|
||||
if (!agentId) return;
|
||||
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const data = await fetchChatSessions(projectId, "active");
|
||||
// Find existing session for this agent
|
||||
const existingSession = data.sessions.find((s) => s.agentId === agentId);
|
||||
|
||||
if (existingSession) {
|
||||
setActiveSession(existingSession);
|
||||
currentAgentIdRef.current = agentId;
|
||||
} else {
|
||||
// Create a new session for this agent
|
||||
const newSession = await createChatSession({ agentId }, projectId);
|
||||
setActiveSession(newSession.session);
|
||||
currentAgentIdRef.current = agentId;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useQuickChat] Failed to initialize session:", err);
|
||||
addToast?.("Failed to initialize chat", "error");
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId, addToast],
|
||||
);
|
||||
|
||||
// Load messages for the active session
|
||||
const loadMessages = useCallback(async () => {
|
||||
if (!activeSession) return;
|
||||
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const data = await fetchChatMessages(activeSession.id, { limit: 50 }, projectId);
|
||||
// Reverse to show oldest first
|
||||
setMessages(data.messages.reverse());
|
||||
} catch (err) {
|
||||
console.error("[useQuickChat] Failed to load messages:", err);
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}, [activeSession, projectId]);
|
||||
|
||||
// Load messages when session changes
|
||||
useEffect(() => {
|
||||
if (activeSession) {
|
||||
void loadMessages();
|
||||
} else {
|
||||
setMessages([]);
|
||||
}
|
||||
}, [activeSession, loadMessages]);
|
||||
|
||||
// Switch to a different agent's session
|
||||
const switchSession = useCallback(
|
||||
async (agentId: string) => {
|
||||
if (agentId === currentAgentIdRef.current) return;
|
||||
|
||||
// Close any existing stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current.close();
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Reset streaming state
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setIsStreaming(false);
|
||||
|
||||
// Initialize session for new agent
|
||||
await initializeSession(agentId);
|
||||
},
|
||||
[initializeSession],
|
||||
);
|
||||
|
||||
// Send a message using SSE streaming
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!activeSession || !content.trim()) return;
|
||||
|
||||
// Close any existing stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current.close();
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Optimistically add user message
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const userMessage: ChatMessageInfo = {
|
||||
id: tempId,
|
||||
sessionId: activeSession.id,
|
||||
role: "user",
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
// Clear streaming state
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setIsStreaming(true);
|
||||
|
||||
// Accumulate streaming text in local variables
|
||||
let capturedText = "";
|
||||
let capturedThinking = "";
|
||||
|
||||
const textHandlers = {
|
||||
onThinking: (data: string) => {
|
||||
capturedThinking += data;
|
||||
setStreamingThinking(capturedThinking);
|
||||
},
|
||||
onText: (data: string) => {
|
||||
capturedText += data;
|
||||
setStreamingText(capturedText);
|
||||
},
|
||||
onDone: (data: { messageId: string }) => {
|
||||
const assistantMessage: ChatMessageInfo = {
|
||||
id: data.messageId || `msg-${Date.now()}`,
|
||||
sessionId: activeSession.id,
|
||||
role: "assistant",
|
||||
content: capturedText,
|
||||
thinkingOutput: capturedThinking || undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => {
|
||||
const withoutTemp = prev.filter((m) => m.id !== tempId);
|
||||
return [...withoutTemp, assistantMessage];
|
||||
});
|
||||
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setIsStreaming(false);
|
||||
streamRef.current = null;
|
||||
},
|
||||
onError: (data: string) => {
|
||||
// Remove the optimistic user message on error
|
||||
setMessages((prev) => prev.filter((m) => m.id !== tempId));
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setIsStreaming(false);
|
||||
streamRef.current = null;
|
||||
console.error("[useQuickChat] Stream error:", data);
|
||||
addToast?.("Failed to send message", "error");
|
||||
},
|
||||
};
|
||||
|
||||
streamRef.current = streamChatResponse(activeSession.id, content, textHandlers, projectId);
|
||||
},
|
||||
[activeSession, projectId, addToast],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.close();
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeSession,
|
||||
sessionsLoading,
|
||||
messages,
|
||||
messagesLoading,
|
||||
isStreaming,
|
||||
streamingText,
|
||||
streamingThinking,
|
||||
sendMessage,
|
||||
switchSession,
|
||||
loadMessages,
|
||||
};
|
||||
}
|
||||
@@ -56,6 +56,9 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
const lastVisibilityRefreshRef = useRef<number>(0);
|
||||
const searchQueryRef = useRef(searchQuery);
|
||||
const refreshTasksRef = useRef<typeof refreshTasks>(null!);
|
||||
// Tracks when task data was last confirmed fresh by the server.
|
||||
// Used to prevent false positives in stuck detection when tab has been in background.
|
||||
const lastFetchTimeMs = useRef<number | undefined>(undefined);
|
||||
tasksRef.current = tasks;
|
||||
searchQueryRef.current = searchQuery;
|
||||
|
||||
@@ -72,6 +75,8 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
return;
|
||||
}
|
||||
setTasks(fetchedTasks.map(normalizeTask));
|
||||
// Record when we received fresh server data for stuck detection
|
||||
lastFetchTimeMs.current = Date.now();
|
||||
} catch {
|
||||
if (fetchVersionRef.current !== requestVersion) {
|
||||
return;
|
||||
@@ -187,6 +192,8 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
t.id === normalizedTask.id ? { ...normalizedTask, column: to } : t
|
||||
)
|
||||
);
|
||||
// Record when we received fresh server data for stuck detection
|
||||
lastFetchTimeMs.current = Date.now();
|
||||
};
|
||||
|
||||
const handleUpdated = (e: MessageEvent) => {
|
||||
@@ -222,6 +229,8 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
return incoming;
|
||||
})
|
||||
);
|
||||
// Record when we received fresh server data for stuck detection
|
||||
lastFetchTimeMs.current = Date.now();
|
||||
};
|
||||
|
||||
const handleDeleted = (e: MessageEvent) => {
|
||||
@@ -394,5 +403,5 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
return normalized;
|
||||
}, [projectId]);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived };
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, lastFetchTimeMs: lastFetchTimeMs.current };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user