feat(FN-4123): restore latest quick chat session on startup
Restores the most recent quick chat session when the FAB is reopened, eliminating the need to navigate to the full chat view to continue a conversation. The feature lives in `QuickChatFAB.tsx` with corresponding tests, and includes documentation and a changeset for the `@runfusion/fusion` patch rele Fusion-Task-Id: FN-4123
This commit is contained in:
@@ -937,6 +937,9 @@ export function QuickChatFAB({
|
||||
const didDragRef = useRef(false);
|
||||
const modelsRequestedRef = useRef(false);
|
||||
const prevSessionTargetRef = useRef("");
|
||||
const hasAppliedInitialSessionRef = useRef(false);
|
||||
const selectedAgentIdRef = useRef(selectedAgentId);
|
||||
const selectedModelRef = useRef(selectedModel);
|
||||
const mentionCursorPosRef = useRef(0);
|
||||
const hideMentionPopupTimeoutRef = useRef<number | null>(null);
|
||||
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
|
||||
@@ -1109,6 +1112,18 @@ export function QuickChatFAB({
|
||||
|
||||
const hasChatTarget = chatMode === "agent" ? Boolean(selectedAgentId) : Boolean(targetModelSelection);
|
||||
const inputDisabled = !hasChatTarget || !activeSession;
|
||||
const hasPersistedAgentSessionSelection = useMemo(
|
||||
() => Boolean(selectedAgentId) && sessions.some((session) => !session.modelProvider && !session.modelId && session.agentId === selectedAgentId),
|
||||
[selectedAgentId, sessions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
selectedAgentIdRef.current = selectedAgentId;
|
||||
}, [selectedAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedModelRef.current = selectedModel;
|
||||
}, [selectedModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) {
|
||||
@@ -1117,11 +1132,15 @@ export function QuickChatFAB({
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAppliedInitialSessionRef.current && hasPersistedAgentSessionSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedStillExists = agents.some((agent) => agent.id === selectedAgentId);
|
||||
if (!selectedStillExists) {
|
||||
setSelectedAgentId(agents[0]?.id ?? "");
|
||||
}
|
||||
}, [agents, selectedAgentId]);
|
||||
}, [agents, hasPersistedAgentSessionSelection, selectedAgentId]);
|
||||
|
||||
// Lazy-load models on first panel open.
|
||||
useEffect(() => {
|
||||
@@ -1137,7 +1156,7 @@ export function QuickChatFAB({
|
||||
const loadedModels = response.models ?? [];
|
||||
setModels(loadedModels);
|
||||
|
||||
if (selectedModel || loadedModels.length === 0) {
|
||||
if (selectedModelRef.current || loadedModels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1150,13 +1169,17 @@ export function QuickChatFAB({
|
||||
);
|
||||
if (hasDefaultModel) {
|
||||
setConfiguredDefaultModelSelection(defaultSelection);
|
||||
setSelectedModel(defaultSelection);
|
||||
if (!selectedModelRef.current) {
|
||||
setSelectedModel(defaultSelection);
|
||||
}
|
||||
// Switch to model mode regardless of whether agents are present —
|
||||
// a configured default model is an explicit user preference and
|
||||
// should drive the panel to its corresponding mode immediately,
|
||||
// otherwise the tag/dropdown auto-selection would be invisible
|
||||
// until the user manually toggles modes.
|
||||
setChatMode("model");
|
||||
if (!hasAppliedInitialSessionRef.current) {
|
||||
setChatMode("model");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1166,7 +1189,7 @@ export function QuickChatFAB({
|
||||
// Always pre-select the first model so users can start chatting in model mode
|
||||
// without having to manually pick from the dropdown.
|
||||
const firstModel = loadedModels[0];
|
||||
if (firstModel) {
|
||||
if (firstModel && !selectedModelRef.current) {
|
||||
setSelectedModel(`${firstModel.provider}/${firstModel.id}`);
|
||||
}
|
||||
})
|
||||
@@ -1203,6 +1226,27 @@ export function QuickChatFAB({
|
||||
void refreshSessions();
|
||||
}, [isOpen, refreshSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || sessionsLoading || hasAppliedInitialSessionRef.current || sessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latestSession = sessions[0];
|
||||
if (!latestSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (latestSession.modelProvider && latestSession.modelId) {
|
||||
setChatMode("model");
|
||||
setSelectedModel(`${latestSession.modelProvider}/${latestSession.modelId}`);
|
||||
} else {
|
||||
setChatMode("agent");
|
||||
setSelectedAgentId(latestSession.agentId);
|
||||
}
|
||||
|
||||
hasAppliedInitialSessionRef.current = true;
|
||||
}, [isOpen, sessions, sessionsLoading]);
|
||||
|
||||
// Initialize/switch quick chat session whenever the selected target changes.
|
||||
// NOTE: activeSession and sessionsLoading are in the dependency array to
|
||||
// enable retry-when-null (see shouldRetrySessionInit), but the hook's
|
||||
@@ -1586,6 +1630,8 @@ export function QuickChatFAB({
|
||||
return;
|
||||
}
|
||||
|
||||
hasAppliedInitialSessionRef.current = true;
|
||||
|
||||
if (selectedSession.modelProvider && selectedSession.modelId) {
|
||||
setChatMode("model");
|
||||
setSelectedModel(`${selectedSession.modelProvider}/${selectedSession.modelId}`);
|
||||
@@ -1600,6 +1646,8 @@ export function QuickChatFAB({
|
||||
const handleCreateFreshSession = useCallback(async () => {
|
||||
if (sessionsLoading) return;
|
||||
|
||||
hasAppliedInitialSessionRef.current = true;
|
||||
|
||||
if (newSessionMode === "agent") {
|
||||
if (!newSessionAgentId) return;
|
||||
setChatMode("agent");
|
||||
|
||||
@@ -53,6 +53,14 @@ const modelSession: ChatSession = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const modelSessionAnthropic: ChatSession = {
|
||||
...modelSession,
|
||||
id: "session-model-anthropic",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-3-7-sonnet",
|
||||
title: "Claude thread",
|
||||
};
|
||||
|
||||
const agentSession: ChatSession = {
|
||||
id: "session-agent",
|
||||
agentId: "agent-001",
|
||||
@@ -65,6 +73,25 @@ const agentSession: ChatSession = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const agentTwoSession: ChatSession = {
|
||||
...agentSession,
|
||||
id: "session-agent-two",
|
||||
agentId: "agent-002",
|
||||
title: "Agent Two thread",
|
||||
};
|
||||
|
||||
function resolveResumeSession(agentId: string, modelProvider?: string, modelId?: string): ChatSession {
|
||||
if (agentId === "agent-002") {
|
||||
return agentTwoSession;
|
||||
}
|
||||
|
||||
if (agentId === "__fn_agent__" && modelProvider === "anthropic" && modelId === "claude-3-7-sonnet") {
|
||||
return modelSessionAnthropic;
|
||||
}
|
||||
|
||||
return modelSession;
|
||||
}
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -89,7 +116,9 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
viewportOffsetTop: 0,
|
||||
keyboardOpen: false,
|
||||
});
|
||||
mockFetchResumeChatSession.mockResolvedValue({ session: modelSession });
|
||||
mockFetchResumeChatSession.mockImplementation(async ({ agentId, modelProvider, modelId }) => ({
|
||||
session: resolveResumeSession(agentId, modelProvider, modelId),
|
||||
}));
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
mockFetchChatSessions.mockResolvedValue({ sessions: [modelSession, agentSession] });
|
||||
mockCreateChatSession.mockResolvedValue({ session: { ...modelSession, id: "session-new" } });
|
||||
@@ -99,7 +128,10 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: true, contextWindow: 128000 }],
|
||||
models: [
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: true, contextWindow: 128000 },
|
||||
{ provider: "anthropic", id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", reasoning: true, contextWindow: 200000 },
|
||||
],
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
defaultProvider: "openai",
|
||||
@@ -131,6 +163,53 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
expect(screen.getByTestId("quick-chat-new-model-select")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores the newest agent session target on first open after reload", async () => {
|
||||
mockFetchChatSessions.mockResolvedValueOnce({
|
||||
sessions: [agentTwoSession, modelSession, agentSession],
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-input")).toHaveAttribute("placeholder", "Message Agent Two");
|
||||
});
|
||||
expect(mockFetchResumeChatSession).toHaveBeenCalledWith({ agentId: "agent-002" }, "proj-1");
|
||||
});
|
||||
|
||||
it("restores the newest model session target instead of the configured default", async () => {
|
||||
mockFetchChatSessions.mockResolvedValueOnce({
|
||||
sessions: [modelSessionAnthropic, modelSession, agentSession],
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-model-tag")).toHaveTextContent("Claude 3.7 Sonnet");
|
||||
});
|
||||
expect(mockFetchResumeChatSession).toHaveBeenCalledWith(
|
||||
{ agentId: "__fn_agent__", modelProvider: "anthropic", modelId: "claude-3-7-sonnet" },
|
||||
"proj-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the existing default target when there are no prior sessions", async () => {
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [] });
|
||||
|
||||
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-model-tag")).toHaveTextContent("GPT-4o");
|
||||
});
|
||||
expect(screen.getByTestId("quick-chat-input")).toHaveAttribute("placeholder", "Message GPT-4o");
|
||||
expect(mockFetchResumeChatSession).toHaveBeenCalledWith(
|
||||
{ agentId: "__fn_agent__", modelProvider: "openai", modelId: "gpt-4o" },
|
||||
"proj-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates fresh model session from inline chooser and closes chooser", async () => {
|
||||
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
Reference in New Issue
Block a user