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:
Fusion
2026-05-12 11:52:59 -07:00
committed by gsxdsm
parent fd2744a9e0
commit 4a60568546
4 changed files with 140 additions and 7 deletions

View 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.

View File

@@ -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 - 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` - 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 - 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 - 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 - 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 - On mobile viewports, opening Quick Chat auto-focuses the composer as soon as it is ready so the keyboard opens immediately

View File

@@ -937,6 +937,9 @@ export function QuickChatFAB({
const didDragRef = useRef(false); const didDragRef = useRef(false);
const modelsRequestedRef = useRef(false); const modelsRequestedRef = useRef(false);
const prevSessionTargetRef = useRef(""); const prevSessionTargetRef = useRef("");
const hasAppliedInitialSessionRef = useRef(false);
const selectedAgentIdRef = useRef(selectedAgentId);
const selectedModelRef = useRef(selectedModel);
const mentionCursorPosRef = useRef(0); const mentionCursorPosRef = useRef(0);
const hideMentionPopupTimeoutRef = useRef<number | null>(null); const hideMentionPopupTimeoutRef = useRef<number | null>(null);
const hideSkillMenuTimeoutRef = 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 hasChatTarget = chatMode === "agent" ? Boolean(selectedAgentId) : Boolean(targetModelSelection);
const inputDisabled = !hasChatTarget || !activeSession; 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(() => { useEffect(() => {
if (agents.length === 0) { if (agents.length === 0) {
@@ -1117,11 +1132,15 @@ export function QuickChatFAB({
return; return;
} }
if (hasAppliedInitialSessionRef.current && hasPersistedAgentSessionSelection) {
return;
}
const selectedStillExists = agents.some((agent) => agent.id === selectedAgentId); const selectedStillExists = agents.some((agent) => agent.id === selectedAgentId);
if (!selectedStillExists) { if (!selectedStillExists) {
setSelectedAgentId(agents[0]?.id ?? ""); setSelectedAgentId(agents[0]?.id ?? "");
} }
}, [agents, selectedAgentId]); }, [agents, hasPersistedAgentSessionSelection, selectedAgentId]);
// Lazy-load models on first panel open. // Lazy-load models on first panel open.
useEffect(() => { useEffect(() => {
@@ -1137,7 +1156,7 @@ export function QuickChatFAB({
const loadedModels = response.models ?? []; const loadedModels = response.models ?? [];
setModels(loadedModels); setModels(loadedModels);
if (selectedModel || loadedModels.length === 0) { if (selectedModelRef.current || loadedModels.length === 0) {
return; return;
} }
@@ -1150,13 +1169,17 @@ export function QuickChatFAB({
); );
if (hasDefaultModel) { if (hasDefaultModel) {
setConfiguredDefaultModelSelection(defaultSelection); setConfiguredDefaultModelSelection(defaultSelection);
setSelectedModel(defaultSelection); if (!selectedModelRef.current) {
setSelectedModel(defaultSelection);
}
// Switch to model mode regardless of whether agents are present — // Switch to model mode regardless of whether agents are present —
// a configured default model is an explicit user preference and // a configured default model is an explicit user preference and
// should drive the panel to its corresponding mode immediately, // should drive the panel to its corresponding mode immediately,
// otherwise the tag/dropdown auto-selection would be invisible // otherwise the tag/dropdown auto-selection would be invisible
// until the user manually toggles modes. // until the user manually toggles modes.
setChatMode("model"); if (!hasAppliedInitialSessionRef.current) {
setChatMode("model");
}
return; return;
} }
} }
@@ -1166,7 +1189,7 @@ export function QuickChatFAB({
// Always pre-select the first model so users can start chatting in model mode // Always pre-select the first model so users can start chatting in model mode
// without having to manually pick from the dropdown. // without having to manually pick from the dropdown.
const firstModel = loadedModels[0]; const firstModel = loadedModels[0];
if (firstModel) { if (firstModel && !selectedModelRef.current) {
setSelectedModel(`${firstModel.provider}/${firstModel.id}`); setSelectedModel(`${firstModel.provider}/${firstModel.id}`);
} }
}) })
@@ -1203,6 +1226,27 @@ export function QuickChatFAB({
void refreshSessions(); void refreshSessions();
}, [isOpen, 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. // Initialize/switch quick chat session whenever the selected target changes.
// NOTE: activeSession and sessionsLoading are in the dependency array to // NOTE: activeSession and sessionsLoading are in the dependency array to
// enable retry-when-null (see shouldRetrySessionInit), but the hook's // enable retry-when-null (see shouldRetrySessionInit), but the hook's
@@ -1586,6 +1630,8 @@ export function QuickChatFAB({
return; return;
} }
hasAppliedInitialSessionRef.current = true;
if (selectedSession.modelProvider && selectedSession.modelId) { if (selectedSession.modelProvider && selectedSession.modelId) {
setChatMode("model"); setChatMode("model");
setSelectedModel(`${selectedSession.modelProvider}/${selectedSession.modelId}`); setSelectedModel(`${selectedSession.modelProvider}/${selectedSession.modelId}`);
@@ -1600,6 +1646,8 @@ export function QuickChatFAB({
const handleCreateFreshSession = useCallback(async () => { const handleCreateFreshSession = useCallback(async () => {
if (sessionsLoading) return; if (sessionsLoading) return;
hasAppliedInitialSessionRef.current = true;
if (newSessionMode === "agent") { if (newSessionMode === "agent") {
if (!newSessionAgentId) return; if (!newSessionAgentId) return;
setChatMode("agent"); setChatMode("agent");

View File

@@ -53,6 +53,14 @@ const modelSession: ChatSession = {
updatedAt: new Date().toISOString(), 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 = { const agentSession: ChatSession = {
id: "session-agent", id: "session-agent",
agentId: "agent-001", agentId: "agent-001",
@@ -65,6 +73,25 @@ const agentSession: ChatSession = {
updatedAt: new Date().toISOString(), 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>() { function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void; let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void; let reject!: (reason?: unknown) => void;
@@ -89,7 +116,9 @@ describe("QuickChatFAB session-first UX", () => {
viewportOffsetTop: 0, viewportOffsetTop: 0,
keyboardOpen: false, keyboardOpen: false,
}); });
mockFetchResumeChatSession.mockResolvedValue({ session: modelSession }); mockFetchResumeChatSession.mockImplementation(async ({ agentId, modelProvider, modelId }) => ({
session: resolveResumeSession(agentId, modelProvider, modelId),
}));
mockFetchChatMessages.mockResolvedValue({ messages: [] }); mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockFetchChatSessions.mockResolvedValue({ sessions: [modelSession, agentSession] }); mockFetchChatSessions.mockResolvedValue({ sessions: [modelSession, agentSession] });
mockCreateChatSession.mockResolvedValue({ session: { ...modelSession, id: "session-new" } }); mockCreateChatSession.mockResolvedValue({ session: { ...modelSession, id: "session-new" } });
@@ -99,7 +128,10 @@ describe("QuickChatFAB session-first UX", () => {
return { close: vi.fn(), isConnected: () => true }; return { close: vi.fn(), isConnected: () => true };
}); });
mockFetchModels.mockResolvedValue({ 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: [], favoriteProviders: [],
favoriteModels: [], favoriteModels: [],
defaultProvider: "openai", defaultProvider: "openai",
@@ -131,6 +163,53 @@ describe("QuickChatFAB session-first UX", () => {
expect(screen.getByTestId("quick-chat-new-model-select")).toBeInTheDocument(); 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 () => { it("creates fresh model session from inline chooser and closes chooser", async () => {
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />); render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab")); fireEvent.click(screen.getByTestId("quick-chat-fab"));