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:
5
.changeset/FN-4123-quick-chat-latest-session.md
Normal file
5
.changeset/FN-4123-quick-chat-latest-session.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Restore Quick Chat so reopening the dashboard resumes the most recently updated session instead of always defaulting to the first agent or default model.
|
||||
@@ -138,6 +138,7 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv
|
||||
- Entering `/new` or `/clear` (exact match after trimming) in the Quick Chat composer uses explicit fresh-session creation for the currently selected target (`startFreshSession()`), so the current thread resets without sending the command text to the model
|
||||
- The `+` action opens an inline new-session chooser (inside the panel, not a modal) with `Model` selected by default and optional switch to `Agent`
|
||||
- Submitting the inline chooser uses explicit fresh-session creation and immediately persists/selects the new thread, then refreshes the session dropdown list
|
||||
- On open after reload, Quick Chat restores the most recently used session (newest `updatedAt`); only when no prior session exists does it fall back to the first agent / configured default model.
|
||||
- Resume lookups still use targeted session queries instead of loading the full active-session list first
|
||||
- Tool-call summaries in the floating quick-chat panel are intentionally condensed into a single-line header row (especially on small screens) so tool name + status stay scannable without multi-line wrapping
|
||||
- On mobile viewports, opening Quick Chat auto-focuses the composer as soon as it is ready so the keyboard opens immediately
|
||||
|
||||
@@ -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