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

@@ -45,9 +45,11 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv
- Supports agent mentions (`@agent`) and file mentions - Supports agent mentions (`@agent`) and file mentions
- Uses the same model/provider infrastructure as full Chat view - Uses the same model/provider infrastructure as full Chat view
- On small screens, compact tool-call summaries in the floating panel intentionally stay single-line (count + tool names + status) to preserve message density - On small screens, compact tool-call summaries in the floating panel intentionally stay single-line (count + tool names + status) to preserve message density
- `switchSession()` resumes the existing thread for the selected target (agent-only or agent+model) - The panel header uses a session-first flow: the main dropdown lists persisted sessions (preferring `session.title`, then falling back to deterministic `Session N` labels)
- `startFreshSession()` is the explicit new-thread action and always creates a new persisted session - Selecting a session from that dropdown resumes the persisted conversation; this keeps `switchSession()` resume-oriented rather than forcing a new thread
- Resume lookups use a targeted session query instead of loading the full active session list first - 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
- 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

@@ -277,9 +277,9 @@
} }
.quick-chat-mode-btn { .quick-chat-mode-btn {
min-width: 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); 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); min-height: var(--quick-chat-header-control-size, calc(var(--space-lg) + var(--space-sm) + var(--space-xs)));
padding: 0 var(--space-sm); padding: 0 var(--space-sm);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -340,6 +340,32 @@
border-bottom: 1px solid var(--border); 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 { .quick-chat-panel-agent-select select {
width: 100%; width: 100%;
background: var(--bg); background: var(--bg);
@@ -746,6 +772,24 @@
min-width: 0; 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 { .quick-chat-panel .chat-tool-calls-group-summary {
flex-wrap: nowrap; flex-wrap: nowrap;
flex-direction: row; flex-direction: row;

View File

@@ -812,6 +812,10 @@ export function QuickChatFAB({
const [chatMode, setChatMode] = useState<"agent" | "model">("agent"); const [chatMode, setChatMode] = useState<"agent" | "model">("agent");
const [selectedAgentId, setSelectedAgentId] = useState<string>(""); 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 [models, setModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false); const [modelsLoading, setModelsLoading] = useState(false);
const [selectedModel, setSelectedModel] = useState<string>(""); const [selectedModel, setSelectedModel] = useState<string>("");
@@ -876,6 +880,7 @@ export function QuickChatFAB({
streamingText, streamingText,
streamingThinking, streamingThinking,
streamingToolCalls, streamingToolCalls,
sessions,
sessionsLoading, sessionsLoading,
messagesLoading, messagesLoading,
sendMessage, sendMessage,
@@ -883,8 +888,10 @@ export function QuickChatFAB({
pendingMessage, pendingMessage,
clearPendingMessage, clearPendingMessage,
switchSession, switchSession,
selectSession,
startModelChat, startModelChat,
startFreshSession, startFreshSession,
refreshSessions,
} = useQuickChat(projectId, addToast); } = useQuickChat(projectId, addToast);
const panelRef = useRef<HTMLDivElement | null>(null); const panelRef = useRef<HTMLDivElement | null>(null);
@@ -1078,6 +1085,11 @@ export function QuickChatFAB({
}); });
}, [isOpen, agents.length, selectedModel]); }, [isOpen, agents.length, selectedModel]);
useEffect(() => {
if (!isOpen) return;
void refreshSessions();
}, [isOpen, refreshSessions]);
// Initialize/switch quick chat session whenever the selected target changes. // Initialize/switch quick chat session whenever the selected target changes.
useEffect(() => { useEffect(() => {
if (!isOpen) { 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(() => { const handleStartFreshChat = useCallback(() => {
if (!hasChatTarget || sessionsLoading) { setNewSessionChooserOpen(true);
return; setNewSessionMode("model");
} setNewSessionAgentId(agents[0]?.id ?? "");
void startFreshSession(); setNewSessionModel(selectedModel || configuredDefaultModelSelection || "");
}, [hasChatTarget, sessionsLoading, startFreshSession]); }, [agents, configuredDefaultModelSelection, selectedModel]);
const selectedAgent = useMemo( const selectedAgent = useMemo(
() => agents.find((agent) => agent.id === selectedAgentId) ?? null, () => agents.find((agent) => agent.id === selectedAgentId) ?? null,
@@ -1287,6 +1291,14 @@ export function QuickChatFAB({
messagesEl.scrollTop = messagesEl.scrollHeight; messagesEl.scrollTop = messagesEl.scrollHeight;
}, [messages, streamingText, streamingThinking, isOpen]); }, [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(() => { const inputPlaceholder = useMemo(() => {
if (chatMode === "agent") { if (chatMode === "agent") {
if (selectedAgent) { if (selectedAgent) {
@@ -1301,6 +1313,53 @@ export function QuickChatFAB({
return "Select a model to start chatting"; return "Select a model to start chatting";
}, [chatMode, selectedAgent, selectedModelTag]); }, [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 const pendingPreview = pendingMessage.length > 50
? `${pendingMessage.slice(0, 50)}…` ? `${pendingMessage.slice(0, 50)}…`
: pendingMessage; : pendingMessage;
@@ -1827,43 +1886,13 @@ export function QuickChatFAB({
})()} })()}
</div> </div>
<div className="quick-chat-panel-header-actions"> <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 <button
type="button" type="button"
className="btn-icon quick-chat-new-chat-btn" className="btn-icon quick-chat-new-chat-btn"
data-testid="quick-chat-new-thread" data-testid="quick-chat-new-thread"
aria-label="Start a new chat" aria-label="Start a new chat"
onClick={handleStartFreshChat} onClick={handleStartFreshChat}
disabled={!hasChatTarget || sessionsLoading} disabled={sessionsLoading}
> >
<Plus size={16} /> <Plus size={16} />
</button> </button>
@@ -1879,40 +1908,96 @@ export function QuickChatFAB({
</div> </div>
</div> </div>
{chatMode === "agent" && agents.length > 0 && ( <div className="quick-chat-panel-agent-select" data-testid="quick-chat-session-select">
<div className="quick-chat-panel-agent-select"> <label htmlFor="quick-chat-session-select" className="visually-hidden">Select session</label>
<label htmlFor="quick-chat-agent-select" className="visually-hidden">Select agent</label> <select
<select id="quick-chat-session-select"
id="quick-chat-agent-select" value={activeSession?.id ?? ""}
value={selectedAgentId} onChange={(event) => handleSessionSwitch(event.target.value)}
onChange={(event) => handleAgentChange(event.target.value)} data-testid="quick-chat-session-dropdown"
data-testid="quick-chat-agent-select" >
> <option value="" disabled>{sessionsLoading ? "Loading sessions…" : "Select a session"}</option>
{agents.map((agent) => ( {sessionOptions.map((sessionOption) => (
<option key={agent.id} value={agent.id}> <option key={sessionOption.id} value={sessionOption.id}>{sessionOption.label}</option>
{getAgentLabel(agent)} ))}
</option> </select>
))} </div>
</select>
</div>
)}
{chatMode === "model" && ( {newSessionChooserOpen && (
<div className="quick-chat-panel-agent-select" data-testid="quick-chat-model-select"> <div className="quick-chat-new-session-chooser" data-testid="quick-chat-new-session-chooser">
<label htmlFor="quick-chat-model-override" className="visually-hidden">Select model override</label> <div className="quick-chat-inline-mode-toggle" data-testid="quick-chat-inline-mode-toggle">
<CustomModelDropdown <button
id="quick-chat-model-override" type="button"
models={models} className={`quick-chat-mode-btn${newSessionMode === "model" ? " quick-chat-mode-btn--active" : ""}`}
value={selectedModel} data-testid="quick-chat-inline-mode-model"
onChange={handleModelChange} onClick={() => setNewSessionMode("model")}
label="Select model override" >
placeholder={modelsLoading ? "Loading models…" : "Select a model"} Model
disabled={modelsLoading || models.length === 0} </button>
favoriteProviders={favoriteProviders} <button
favoriteModels={favoriteModels} type="button"
onToggleFavorite={onToggleFavorite} className={`quick-chat-mode-btn${newSessionMode === "agent" ? " quick-chat-mode-btn--active" : ""}`}
onToggleModelFavorite={onToggleModelFavorite} 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> </div>
)} )}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ChatMessage, ChatSession } from "@fusion/core"; import type { ChatMessage, ChatSession } from "@fusion/core";
import { import {
fetchResumeChatSession, fetchResumeChatSession,
fetchChatSessions,
createChatSession, createChatSession,
fetchChatMessages, fetchChatMessages,
streamChatResponse, streamChatResponse,
@@ -42,6 +43,7 @@ interface SessionTarget {
export interface UseQuickChatReturn { export interface UseQuickChatReturn {
// Session state // Session state
activeSession: ChatSession | null; activeSession: ChatSession | null;
sessions: ChatSession[];
sessionsLoading: boolean; sessionsLoading: boolean;
// Message state // Message state
@@ -58,8 +60,10 @@ export interface UseQuickChatReturn {
stopStreaming: () => void; stopStreaming: () => void;
clearPendingMessage: () => void; clearPendingMessage: () => void;
switchSession: (agentId: string, modelProvider?: string, modelId?: string) => Promise<void>; switchSession: (agentId: string, modelProvider?: string, modelId?: string) => Promise<void>;
selectSession: (session: ChatSession) => Promise<void>;
startModelChat: (modelProvider: string, modelId: string) => 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>; loadMessages: () => Promise<void>;
reloadMessages: () => Promise<void>; reloadMessages: () => Promise<void>;
} }
@@ -152,6 +156,7 @@ export function useQuickChat(
): UseQuickChatReturn { ): UseQuickChatReturn {
// Session state // Session state
const [activeSession, setActiveSession] = useState<ChatSession | null>(null); const [activeSession, setActiveSession] = useState<ChatSession | null>(null);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false); const [sessionsLoading, setSessionsLoading] = useState(false);
// Message state // Message state
@@ -178,6 +183,18 @@ export function useQuickChat(
pendingMessageRef.current = pendingMessage; pendingMessageRef.current = pendingMessage;
}, [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( const createSessionForTarget = useCallback(
async (target: SessionTarget): Promise<ChatSession> => { async (target: SessionTarget): Promise<ChatSession> => {
const newSessionInput: { agentId: string; modelProvider?: string; modelId?: string } = { const newSessionInput: { agentId: string; modelProvider?: string; modelId?: string } = {
@@ -313,6 +330,25 @@ export function useQuickChat(
[initializeSession, reloadMessages, activeSession], [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( const startModelChat = useCallback(
async (modelProvider: string, modelId: string) => { async (modelProvider: string, modelId: string) => {
await switchSession(FN_AGENT_ID, modelProvider, modelId); await switchSession(FN_AGENT_ID, modelProvider, modelId);
@@ -320,10 +356,13 @@ export function useQuickChat(
[switchSession], [switchSession],
); );
const startFreshSession = useCallback(async () => { const startFreshSession = useCallback(async (agentId?: string, modelProvider?: string, modelId?: string) => {
const target = currentSessionTargetRef.current; const overrideTarget = resolveSessionTarget(agentId ?? "", modelProvider, modelId);
const target = overrideTarget ?? currentSessionTargetRef.current;
if (!target) return; if (!target) return;
currentSessionTargetRef.current = target;
// Explicit "new chat" action: keep the same target key but create a new persisted session. // 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. // This preserves normal switchSession resume behavior while allowing multiple threads per target.
const targetSessionKey = buildSessionKey(target.agentId, target.modelProvider, target.modelId); const targetSessionKey = buildSessionKey(target.agentId, target.modelProvider, target.modelId);
@@ -345,13 +384,16 @@ export function useQuickChat(
const newSession = await createSessionForTarget(target); const newSession = await createSessionForTarget(target);
setActiveSession(newSession); setActiveSession(newSession);
currentSessionKeyRef.current = targetSessionKey; currentSessionKeyRef.current = targetSessionKey;
const sessionList = await fetchChatSessions(projectId);
setSessions(sessionList.sessions);
} catch (err) { } catch (err) {
console.error("[useQuickChat] Failed to start a fresh session:", err); console.error("[useQuickChat] Failed to start a fresh session:", err);
addToast?.("Failed to start a new chat", "error"); addToast?.("Failed to start a new chat", "error");
} finally { } finally {
setSessionsLoading(false); setSessionsLoading(false);
} }
}, [addToast, createSessionForTarget]); }, [addToast, createSessionForTarget, projectId]);
const stopStreaming = useCallback(() => { const stopStreaming = useCallback(() => {
if (!activeSession) return; if (!activeSession) return;
@@ -587,6 +629,7 @@ export function useQuickChat(
return useMemo(() => ({ return useMemo(() => ({
activeSession, activeSession,
sessions,
sessionsLoading, sessionsLoading,
messages, messages,
messagesLoading, messagesLoading,
@@ -599,12 +642,15 @@ export function useQuickChat(
stopStreaming, stopStreaming,
clearPendingMessage, clearPendingMessage,
switchSession, switchSession,
selectSession,
startModelChat, startModelChat,
startFreshSession, startFreshSession,
refreshSessions,
loadMessages, loadMessages,
reloadMessages, reloadMessages,
}), [ }), [
activeSession, activeSession,
sessions,
sessionsLoading, sessionsLoading,
messages, messages,
messagesLoading, messagesLoading,
@@ -617,8 +663,10 @@ export function useQuickChat(
stopStreaming, stopStreaming,
clearPendingMessage, clearPendingMessage,
switchSession, switchSession,
selectSession,
startModelChat, startModelChat,
startFreshSession, startFreshSession,
refreshSessions,
loadMessages, loadMessages,
reloadMessages, reloadMessages,
]); ]);