feat(FN-3058): session-first quick chat with heartbeat prompt improvements
This merge completes the session-first quick chat FAB flow (Step 5), restores the quick chat hook after fixing a styling regression, and fixes heartbeat prompt composition for autonomous agents. It also adds the planning mode question display with new-session reset, introduces new Droid CLI settings Fusion-Task-Id: FN-3058
This commit is contained in:
@@ -277,9 +277,9 @@
|
||||
}
|
||||
|
||||
.quick-chat-mode-btn {
|
||||
min-width: var(--quick-chat-header-control-size);
|
||||
height: var(--quick-chat-header-control-size);
|
||||
min-height: var(--quick-chat-header-control-size);
|
||||
min-width: var(--quick-chat-header-control-size, calc(var(--space-lg) + var(--space-sm) + var(--space-xs)));
|
||||
height: var(--quick-chat-header-control-size, calc(var(--space-lg) + var(--space-sm) + var(--space-xs)));
|
||||
min-height: var(--quick-chat-header-control-size, calc(var(--space-lg) + var(--space-sm) + var(--space-xs)));
|
||||
padding: 0 var(--space-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -340,6 +340,32 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.quick-chat-new-session-chooser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin: var(--space-sm) var(--space-md) 0;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--todo) 25%, var(--border));
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--surface) 80%, var(--card));
|
||||
}
|
||||
|
||||
.quick-chat-inline-mode-toggle {
|
||||
--quick-chat-header-control-size: calc(var(--space-lg) + var(--space-sm) + var(--space-xs));
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.quick-chat-new-session-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.quick-chat-panel-agent-select select {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
@@ -746,6 +772,24 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-chat-inline-mode-toggle {
|
||||
--quick-chat-header-control-size: calc(var(--space-xl) + var(--space-md));
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quick-chat-mode-btn {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.quick-chat-new-session-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.quick-chat-new-session-actions .btn {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.quick-chat-panel .chat-tool-calls-group-summary {
|
||||
flex-wrap: nowrap;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -812,6 +812,10 @@ export function QuickChatFAB({
|
||||
|
||||
const [chatMode, setChatMode] = useState<"agent" | "model">("agent");
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||||
const [newSessionChooserOpen, setNewSessionChooserOpen] = useState(false);
|
||||
const [newSessionMode, setNewSessionMode] = useState<"agent" | "model">("model");
|
||||
const [newSessionAgentId, setNewSessionAgentId] = useState<string>("");
|
||||
const [newSessionModel, setNewSessionModel] = useState<string>("");
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
@@ -876,6 +880,7 @@ export function QuickChatFAB({
|
||||
streamingText,
|
||||
streamingThinking,
|
||||
streamingToolCalls,
|
||||
sessions,
|
||||
sessionsLoading,
|
||||
messagesLoading,
|
||||
sendMessage,
|
||||
@@ -883,8 +888,10 @@ export function QuickChatFAB({
|
||||
pendingMessage,
|
||||
clearPendingMessage,
|
||||
switchSession,
|
||||
selectSession,
|
||||
startModelChat,
|
||||
startFreshSession,
|
||||
refreshSessions,
|
||||
} = useQuickChat(projectId, addToast);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -1078,6 +1085,11 @@ export function QuickChatFAB({
|
||||
});
|
||||
}, [isOpen, agents.length, selectedModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
void refreshSessions();
|
||||
}, [isOpen, refreshSessions]);
|
||||
|
||||
// Initialize/switch quick chat session whenever the selected target changes.
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
@@ -1201,20 +1213,12 @@ export function QuickChatFAB({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string) => {
|
||||
setSelectedAgentId(agentId);
|
||||
}, []);
|
||||
|
||||
const handleModelChange = useCallback((value: string) => {
|
||||
setSelectedModel(value);
|
||||
}, []);
|
||||
|
||||
const handleStartFreshChat = useCallback(() => {
|
||||
if (!hasChatTarget || sessionsLoading) {
|
||||
return;
|
||||
}
|
||||
void startFreshSession();
|
||||
}, [hasChatTarget, sessionsLoading, startFreshSession]);
|
||||
setNewSessionChooserOpen(true);
|
||||
setNewSessionMode("model");
|
||||
setNewSessionAgentId(agents[0]?.id ?? "");
|
||||
setNewSessionModel(selectedModel || configuredDefaultModelSelection || "");
|
||||
}, [agents, configuredDefaultModelSelection, selectedModel]);
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() => agents.find((agent) => agent.id === selectedAgentId) ?? null,
|
||||
@@ -1287,6 +1291,14 @@ export function QuickChatFAB({
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}, [messages, streamingText, streamingThinking, isOpen]);
|
||||
|
||||
const sessionOptions = useMemo(
|
||||
() => sessions.map((session, index) => ({
|
||||
id: session.id,
|
||||
label: session.title?.trim() || `Session ${index + 1}`,
|
||||
})),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
const inputPlaceholder = useMemo(() => {
|
||||
if (chatMode === "agent") {
|
||||
if (selectedAgent) {
|
||||
@@ -1301,6 +1313,53 @@ export function QuickChatFAB({
|
||||
return "Select a model to start chatting";
|
||||
}, [chatMode, selectedAgent, selectedModelTag]);
|
||||
|
||||
const handleSessionSwitch = useCallback((sessionId: string) => {
|
||||
const selectedSession = sessions.find((session) => session.id === sessionId);
|
||||
if (!selectedSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedSession.modelProvider && selectedSession.modelId) {
|
||||
setChatMode("model");
|
||||
setSelectedModel(`${selectedSession.modelProvider}/${selectedSession.modelId}`);
|
||||
} else {
|
||||
setChatMode("agent");
|
||||
setSelectedAgentId(selectedSession.agentId);
|
||||
}
|
||||
|
||||
void selectSession(selectedSession);
|
||||
}, [selectSession, sessions]);
|
||||
|
||||
const handleCreateFreshSession = useCallback(async () => {
|
||||
if (sessionsLoading) return;
|
||||
|
||||
if (newSessionMode === "agent") {
|
||||
if (!newSessionAgentId) return;
|
||||
setChatMode("agent");
|
||||
setSelectedAgentId(newSessionAgentId);
|
||||
await startFreshSession(newSessionAgentId);
|
||||
} else {
|
||||
const parsed = parseModelSelection(newSessionModel || selectedModel || configuredDefaultModelSelection);
|
||||
if (!parsed) return;
|
||||
setChatMode("model");
|
||||
setSelectedModel(`${parsed.modelProvider}/${parsed.modelId}`);
|
||||
await startFreshSession(FN_AGENT_ID, parsed.modelProvider, parsed.modelId);
|
||||
}
|
||||
|
||||
await refreshSessions();
|
||||
setNewSessionChooserOpen(false);
|
||||
setNewSessionMode("model");
|
||||
}, [
|
||||
configuredDefaultModelSelection,
|
||||
newSessionAgentId,
|
||||
newSessionMode,
|
||||
newSessionModel,
|
||||
refreshSessions,
|
||||
selectedModel,
|
||||
sessionsLoading,
|
||||
startFreshSession,
|
||||
]);
|
||||
|
||||
const pendingPreview = pendingMessage.length > 50
|
||||
? `${pendingMessage.slice(0, 50)}…`
|
||||
: pendingMessage;
|
||||
@@ -1827,43 +1886,13 @@ export function QuickChatFAB({
|
||||
})()}
|
||||
</div>
|
||||
<div className="quick-chat-panel-header-actions">
|
||||
{agents.length > 0 && (
|
||||
<div className="quick-chat-header-mode-toggle" data-testid="quick-chat-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`quick-chat-mode-btn${chatMode === "agent" ? " quick-chat-mode-btn--active" : ""}`}
|
||||
data-testid="quick-chat-mode-agent"
|
||||
onClick={() => {
|
||||
setChatMode("agent");
|
||||
setSelectedModel("");
|
||||
}}
|
||||
aria-label="Switch to agent mode"
|
||||
title="Agent mode"
|
||||
>
|
||||
Agent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`quick-chat-mode-btn${chatMode === "model" ? " quick-chat-mode-btn--active" : ""}`}
|
||||
data-testid="quick-chat-mode-model"
|
||||
onClick={() => {
|
||||
setChatMode("model");
|
||||
setSelectedAgentId("");
|
||||
}}
|
||||
aria-label="Switch to model mode"
|
||||
title="Model mode"
|
||||
>
|
||||
Model
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon quick-chat-new-chat-btn"
|
||||
data-testid="quick-chat-new-thread"
|
||||
aria-label="Start a new chat"
|
||||
onClick={handleStartFreshChat}
|
||||
disabled={!hasChatTarget || sessionsLoading}
|
||||
disabled={sessionsLoading}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
@@ -1879,40 +1908,96 @@ export function QuickChatFAB({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chatMode === "agent" && agents.length > 0 && (
|
||||
<div className="quick-chat-panel-agent-select">
|
||||
<label htmlFor="quick-chat-agent-select" className="visually-hidden">Select agent</label>
|
||||
<select
|
||||
id="quick-chat-agent-select"
|
||||
value={selectedAgentId}
|
||||
onChange={(event) => handleAgentChange(event.target.value)}
|
||||
data-testid="quick-chat-agent-select"
|
||||
>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{getAgentLabel(agent)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="quick-chat-panel-agent-select" data-testid="quick-chat-session-select">
|
||||
<label htmlFor="quick-chat-session-select" className="visually-hidden">Select session</label>
|
||||
<select
|
||||
id="quick-chat-session-select"
|
||||
value={activeSession?.id ?? ""}
|
||||
onChange={(event) => handleSessionSwitch(event.target.value)}
|
||||
data-testid="quick-chat-session-dropdown"
|
||||
>
|
||||
<option value="" disabled>{sessionsLoading ? "Loading sessions…" : "Select a session"}</option>
|
||||
{sessionOptions.map((sessionOption) => (
|
||||
<option key={sessionOption.id} value={sessionOption.id}>{sessionOption.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{chatMode === "model" && (
|
||||
<div className="quick-chat-panel-agent-select" data-testid="quick-chat-model-select">
|
||||
<label htmlFor="quick-chat-model-override" className="visually-hidden">Select model override</label>
|
||||
<CustomModelDropdown
|
||||
id="quick-chat-model-override"
|
||||
models={models}
|
||||
value={selectedModel}
|
||||
onChange={handleModelChange}
|
||||
label="Select model override"
|
||||
placeholder={modelsLoading ? "Loading models…" : "Select a model"}
|
||||
disabled={modelsLoading || models.length === 0}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
/>
|
||||
{newSessionChooserOpen && (
|
||||
<div className="quick-chat-new-session-chooser" data-testid="quick-chat-new-session-chooser">
|
||||
<div className="quick-chat-inline-mode-toggle" data-testid="quick-chat-inline-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`quick-chat-mode-btn${newSessionMode === "model" ? " quick-chat-mode-btn--active" : ""}`}
|
||||
data-testid="quick-chat-inline-mode-model"
|
||||
onClick={() => setNewSessionMode("model")}
|
||||
>
|
||||
Model
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`quick-chat-mode-btn${newSessionMode === "agent" ? " quick-chat-mode-btn--active" : ""}`}
|
||||
data-testid="quick-chat-inline-mode-agent"
|
||||
onClick={() => setNewSessionMode("agent")}
|
||||
>
|
||||
Agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{newSessionMode === "agent" ? (
|
||||
<div className="quick-chat-panel-agent-select">
|
||||
<label htmlFor="quick-chat-new-agent-select" className="visually-hidden">Select agent for new chat</label>
|
||||
<select
|
||||
id="quick-chat-new-agent-select"
|
||||
value={newSessionAgentId}
|
||||
onChange={(event) => setNewSessionAgentId(event.target.value)}
|
||||
data-testid="quick-chat-new-agent-select"
|
||||
>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>{getAgentLabel(agent)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="quick-chat-panel-agent-select" data-testid="quick-chat-new-model-select">
|
||||
<CustomModelDropdown
|
||||
id="quick-chat-new-model-select"
|
||||
models={models}
|
||||
value={newSessionModel}
|
||||
onChange={setNewSessionModel}
|
||||
label="Select model override"
|
||||
placeholder={modelsLoading ? "Loading models…" : "Select a model"}
|
||||
disabled={modelsLoading || models.length === 0}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="quick-chat-new-session-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="quick-chat-new-session-cancel"
|
||||
onClick={() => {
|
||||
setNewSessionChooserOpen(false);
|
||||
setNewSessionMode("model");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="quick-chat-new-session-submit"
|
||||
onClick={() => void handleCreateFreshSession()}
|
||||
disabled={sessionsLoading || (newSessionMode === "agent" ? !newSessionAgentId : !parseModelSelection(newSessionModel || selectedModel || configuredDefaultModelSelection))}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChatMessage, ChatSession } from "@fusion/core";
|
||||
import {
|
||||
fetchResumeChatSession,
|
||||
fetchChatSessions,
|
||||
createChatSession,
|
||||
fetchChatMessages,
|
||||
streamChatResponse,
|
||||
@@ -42,6 +43,7 @@ interface SessionTarget {
|
||||
export interface UseQuickChatReturn {
|
||||
// Session state
|
||||
activeSession: ChatSession | null;
|
||||
sessions: ChatSession[];
|
||||
sessionsLoading: boolean;
|
||||
|
||||
// Message state
|
||||
@@ -58,8 +60,10 @@ export interface UseQuickChatReturn {
|
||||
stopStreaming: () => void;
|
||||
clearPendingMessage: () => void;
|
||||
switchSession: (agentId: string, modelProvider?: string, modelId?: string) => Promise<void>;
|
||||
selectSession: (session: ChatSession) => Promise<void>;
|
||||
startModelChat: (modelProvider: string, modelId: string) => Promise<void>;
|
||||
startFreshSession: () => Promise<void>;
|
||||
startFreshSession: (agentId?: string, modelProvider?: string, modelId?: string) => Promise<void>;
|
||||
refreshSessions: () => Promise<void>;
|
||||
loadMessages: () => Promise<void>;
|
||||
reloadMessages: () => Promise<void>;
|
||||
}
|
||||
@@ -152,6 +156,7 @@ export function useQuickChat(
|
||||
): UseQuickChatReturn {
|
||||
// Session state
|
||||
const [activeSession, setActiveSession] = useState<ChatSession | null>(null);
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||
|
||||
// Message state
|
||||
@@ -178,6 +183,18 @@ export function useQuickChat(
|
||||
pendingMessageRef.current = pendingMessage;
|
||||
}, [pendingMessage]);
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const response = await fetchChatSessions(projectId);
|
||||
setSessions(response.sessions);
|
||||
} catch (err) {
|
||||
console.error("[useQuickChat] Failed to refresh sessions:", err);
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const createSessionForTarget = useCallback(
|
||||
async (target: SessionTarget): Promise<ChatSession> => {
|
||||
const newSessionInput: { agentId: string; modelProvider?: string; modelId?: string } = {
|
||||
@@ -313,6 +330,25 @@ export function useQuickChat(
|
||||
[initializeSession, reloadMessages, activeSession],
|
||||
);
|
||||
|
||||
const selectSession = useCallback(async (session: ChatSession) => {
|
||||
const target = resolveSessionTarget(session.agentId, session.modelProvider ?? undefined, session.modelId ?? undefined);
|
||||
if (!target) return;
|
||||
|
||||
currentSessionTargetRef.current = target;
|
||||
currentSessionKeyRef.current = buildSessionKey(target.agentId, target.modelProvider, target.modelId);
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.close();
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
setActiveSession(session);
|
||||
}, []);
|
||||
|
||||
const startModelChat = useCallback(
|
||||
async (modelProvider: string, modelId: string) => {
|
||||
await switchSession(FN_AGENT_ID, modelProvider, modelId);
|
||||
@@ -320,10 +356,13 @@ export function useQuickChat(
|
||||
[switchSession],
|
||||
);
|
||||
|
||||
const startFreshSession = useCallback(async () => {
|
||||
const target = currentSessionTargetRef.current;
|
||||
const startFreshSession = useCallback(async (agentId?: string, modelProvider?: string, modelId?: string) => {
|
||||
const overrideTarget = resolveSessionTarget(agentId ?? "", modelProvider, modelId);
|
||||
const target = overrideTarget ?? currentSessionTargetRef.current;
|
||||
if (!target) return;
|
||||
|
||||
currentSessionTargetRef.current = target;
|
||||
|
||||
// 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);
|
||||
@@ -345,13 +384,16 @@ export function useQuickChat(
|
||||
const newSession = await createSessionForTarget(target);
|
||||
setActiveSession(newSession);
|
||||
currentSessionKeyRef.current = targetSessionKey;
|
||||
|
||||
const sessionList = await fetchChatSessions(projectId);
|
||||
setSessions(sessionList.sessions);
|
||||
} 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]);
|
||||
}, [addToast, createSessionForTarget, projectId]);
|
||||
|
||||
const stopStreaming = useCallback(() => {
|
||||
if (!activeSession) return;
|
||||
@@ -587,6 +629,7 @@ export function useQuickChat(
|
||||
|
||||
return useMemo(() => ({
|
||||
activeSession,
|
||||
sessions,
|
||||
sessionsLoading,
|
||||
messages,
|
||||
messagesLoading,
|
||||
@@ -599,12 +642,15 @@ export function useQuickChat(
|
||||
stopStreaming,
|
||||
clearPendingMessage,
|
||||
switchSession,
|
||||
selectSession,
|
||||
startModelChat,
|
||||
startFreshSession,
|
||||
refreshSessions,
|
||||
loadMessages,
|
||||
reloadMessages,
|
||||
}), [
|
||||
activeSession,
|
||||
sessions,
|
||||
sessionsLoading,
|
||||
messages,
|
||||
messagesLoading,
|
||||
@@ -617,8 +663,10 @@ export function useQuickChat(
|
||||
stopStreaming,
|
||||
clearPendingMessage,
|
||||
switchSession,
|
||||
selectSession,
|
||||
startModelChat,
|
||||
startFreshSession,
|
||||
refreshSessions,
|
||||
loadMessages,
|
||||
reloadMessages,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user