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:
Fusion
2026-05-01 12:10:06 -07:00
committed by gsxdsm
parent 1c3fec3bd6
commit cf429431cc
5 changed files with 334 additions and 2632 deletions

View File

@@ -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;

View File

@@ -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>
)}

View File

@@ -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,
]);