feat(FN-2306): add fresh quick-chat thread creation flow
- Add startFreshSession to useQuickChat so users can explicitly create a new persisted session for the current agent/model target - Refactor session creation into a shared helper and track current session target to support fresh-thread creation without changing selection - Add a New chat action to the QuickChatFAB header that starts a fresh thread while preserving active model/agent context - Update quick chat hook/component tests to verify new-session behavior and message streaming targets - Add header action styling for the new quick-chat controls
This commit is contained in:
@@ -488,6 +488,7 @@ export function QuickChatFAB({
|
||||
clearPendingMessage,
|
||||
switchSession,
|
||||
startModelChat,
|
||||
startFreshSession,
|
||||
} = useQuickChat(projectId, addToast);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -610,6 +611,13 @@ export function QuickChatFAB({
|
||||
setSelectedModel(value);
|
||||
}, []);
|
||||
|
||||
const handleStartFreshChat = useCallback(() => {
|
||||
if (!hasChatTarget || sessionsLoading) {
|
||||
return;
|
||||
}
|
||||
void startFreshSession();
|
||||
}, [hasChatTarget, sessionsLoading, startFreshSession]);
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() => agents.find((agent) => agent.id === selectedAgentId) ?? null,
|
||||
[agents, selectedAgentId],
|
||||
@@ -988,15 +996,26 @@ export function QuickChatFAB({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
aria-label="Close quick chat"
|
||||
data-testid="quick-chat-close"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<div className="quick-chat-panel-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
data-testid="quick-chat-new-thread"
|
||||
onClick={handleStartFreshChat}
|
||||
disabled={!hasChatTarget || sessionsLoading}
|
||||
>
|
||||
New chat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
aria-label="Close quick chat"
|
||||
data-testid="quick-chat-close"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agents.length > 0 && (
|
||||
|
||||
@@ -364,6 +364,69 @@ describe("QuickChatFAB", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("new chat action creates a fresh model thread without changing the selected model", async () => {
|
||||
const existingModelSession: ChatSession = {
|
||||
id: "session-model-001",
|
||||
agentId: "__fn_agent__",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const freshModelSession: ChatSession = {
|
||||
id: "session-model-002",
|
||||
agentId: "__fn_agent__",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockAgentsHook([]);
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingModelSession] });
|
||||
mockCreateChatSession.mockResolvedValueOnce({ session: freshModelSession });
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-model-tag")).toHaveTextContent("Claude Sonnet 4.5");
|
||||
expect(mockCreateChatSession).not.toHaveBeenCalled();
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-model-001", { limit: 50 }, "proj-123");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-new-thread"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateChatSession).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateChatSession).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: "__fn_agent__",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
},
|
||||
"proj-123",
|
||||
);
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-model-002", { limit: 50 }, "proj-123");
|
||||
});
|
||||
|
||||
const input = await screen.findByTestId("quick-chat-input");
|
||||
fireEvent.change(input, { target: { value: "fresh thread message" } });
|
||||
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledWith(
|
||||
"session-model-002",
|
||||
"fresh thread message",
|
||||
expect.any(Object),
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("switching from model mode to agent mode creates session with only agentId", async () => {
|
||||
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
||||
|
||||
|
||||
@@ -203,6 +203,51 @@ describe("useQuickChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("startFreshSession creates a second session for the same model target", async () => {
|
||||
const existingSession = makeSession({
|
||||
id: "session-existing",
|
||||
agentId: FN_AGENT_ID,
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
});
|
||||
const freshSession = makeSession({
|
||||
id: "session-fresh",
|
||||
agentId: FN_AGENT_ID,
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
});
|
||||
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
|
||||
mockCreateChatSession.mockResolvedValueOnce({ session: freshSession });
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startModelChat("openai", "gpt-4o");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession?.id).toBe("session-existing");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startFreshSession();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateChatSession).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: FN_AGENT_ID,
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
},
|
||||
"proj-123",
|
||||
);
|
||||
expect(result.current.activeSession?.id).toBe("session-fresh");
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-fresh", { limit: 50 }, "proj-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("stopStreaming aborts stream and resets streaming state", async () => {
|
||||
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
|
||||
const closeFn = vi.fn();
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface UseQuickChatReturn {
|
||||
clearPendingMessage: () => void;
|
||||
switchSession: (agentId: string, modelProvider?: string, modelId?: string) => Promise<void>;
|
||||
startModelChat: (modelProvider: string, modelId: string) => Promise<void>;
|
||||
startFreshSession: () => Promise<void>;
|
||||
loadMessages: () => Promise<void>;
|
||||
reloadMessages: () => Promise<void>;
|
||||
}
|
||||
@@ -187,11 +188,29 @@ export function useQuickChat(
|
||||
|
||||
// Track the current selected chat target for session management
|
||||
const currentSessionKeyRef = useRef<string>("");
|
||||
const currentSessionTargetRef = useRef<SessionTarget | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
pendingMessageRef.current = pendingMessage;
|
||||
}, [pendingMessage]);
|
||||
|
||||
const createSessionForTarget = useCallback(
|
||||
async (target: SessionTarget): Promise<ChatSession> => {
|
||||
const newSessionInput: { agentId: string; modelProvider?: string; modelId?: string } = {
|
||||
agentId: target.agentId,
|
||||
};
|
||||
|
||||
if (target.modelProvider && target.modelId) {
|
||||
newSessionInput.modelProvider = target.modelProvider;
|
||||
newSessionInput.modelId = target.modelId;
|
||||
}
|
||||
|
||||
const newSession = await createChatSession(newSessionInput, projectId);
|
||||
return newSession.session;
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
// Fetch existing sessions and find/create one for the given target
|
||||
const initializeSession = useCallback(
|
||||
async (agentId: string, modelProvider?: string, modelId?: string) => {
|
||||
@@ -209,17 +228,8 @@ export function useQuickChat(
|
||||
setActiveSession(existingSession);
|
||||
currentSessionKeyRef.current = sessionKey;
|
||||
} else {
|
||||
const newSessionInput: { agentId: string; modelProvider?: string; modelId?: string } = {
|
||||
agentId: target.agentId,
|
||||
};
|
||||
|
||||
if (target.modelProvider && target.modelId) {
|
||||
newSessionInput.modelProvider = target.modelProvider;
|
||||
newSessionInput.modelId = target.modelId;
|
||||
}
|
||||
|
||||
const newSession = await createChatSession(newSessionInput, projectId);
|
||||
setActiveSession(newSession.session);
|
||||
const newSession = await createSessionForTarget(target);
|
||||
setActiveSession(newSession);
|
||||
currentSessionKeyRef.current = sessionKey;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -229,7 +239,7 @@ export function useQuickChat(
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId, addToast],
|
||||
[projectId, addToast, createSessionForTarget],
|
||||
);
|
||||
|
||||
// Load messages for the active session
|
||||
@@ -277,6 +287,7 @@ export function useQuickChat(
|
||||
if (!target) return;
|
||||
|
||||
const targetSessionKey = buildSessionKey(target.agentId, target.modelProvider, target.modelId);
|
||||
currentSessionTargetRef.current = target;
|
||||
|
||||
// Close any existing stream
|
||||
if (streamRef.current) {
|
||||
@@ -314,6 +325,38 @@ export function useQuickChat(
|
||||
[switchSession],
|
||||
);
|
||||
|
||||
const startFreshSession = useCallback(async () => {
|
||||
const target = currentSessionTargetRef.current;
|
||||
if (!target) return;
|
||||
|
||||
// Explicit "new chat" action: keep the same target key but create a new persisted session.
|
||||
// This preserves normal switchSession resume behavior while allowing multiple threads per target.
|
||||
const targetSessionKey = buildSessionKey(target.agentId, target.modelProvider, target.modelId);
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.close();
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
setMessages([]);
|
||||
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const newSession = await createSessionForTarget(target);
|
||||
setActiveSession(newSession);
|
||||
currentSessionKeyRef.current = targetSessionKey;
|
||||
} catch (err) {
|
||||
console.error("[useQuickChat] Failed to start a fresh session:", err);
|
||||
addToast?.("Failed to start a new chat", "error");
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
}, [addToast, createSessionForTarget]);
|
||||
|
||||
const stopStreaming = useCallback(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
@@ -505,6 +548,7 @@ export function useQuickChat(
|
||||
clearPendingMessage,
|
||||
switchSession,
|
||||
startModelChat,
|
||||
startFreshSession,
|
||||
loadMessages,
|
||||
reloadMessages,
|
||||
}), [
|
||||
@@ -522,6 +566,7 @@ export function useQuickChat(
|
||||
clearPendingMessage,
|
||||
switchSession,
|
||||
startModelChat,
|
||||
startFreshSession,
|
||||
loadMessages,
|
||||
reloadMessages,
|
||||
]);
|
||||
|
||||
@@ -32388,6 +32388,16 @@ html .column.drag-over * {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.quick-chat-panel-header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.quick-chat-panel-header-actions .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-chat-mode-toggle {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
|
||||
Reference in New Issue
Block a user