feat(FN-2000): add memory auto-summarize settings and automation sync
- Add Memory section controls for enabling auto-summarize with threshold and cron schedule inputs - Wire ProjectEngine to sync auto-summarize automation on startup and when related settings change - Reuse a single startup settings snapshot when syncing insight extraction and auto-summarize automations - Add SettingsModal and ProjectEngine tests covering auto-summarize UI persistence and automation re-sync behavior
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
MessageSquare,
|
||||
Send,
|
||||
@@ -16,6 +16,7 @@ import type { Agent } from "@fusion/core";
|
||||
import type { DiscoveredSkill } from "@fusion/dashboard";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||
|
||||
export interface ChatViewProps {
|
||||
projectId?: string;
|
||||
@@ -127,6 +128,25 @@ function getSkillTriggerMatch(value: string): { filter: string; start: number; e
|
||||
};
|
||||
}
|
||||
|
||||
function getMentionTriggerMatch(
|
||||
value: string,
|
||||
cursorPos: number,
|
||||
): { filter: string; start: number; end: number } | null {
|
||||
const textBeforeCursor = value.slice(0, cursorPos);
|
||||
const triggerMatch = /(^|[\s\n])@([\w-]*)$/.exec(textBeforeCursor);
|
||||
if (!triggerMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filter = triggerMatch[2] ?? "";
|
||||
const start = textBeforeCursor.length - filter.length - 1;
|
||||
return {
|
||||
filter,
|
||||
start,
|
||||
end: cursorPos,
|
||||
};
|
||||
}
|
||||
|
||||
interface NewChatDialogProps {
|
||||
onClose: () => void;
|
||||
onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string }) => void;
|
||||
@@ -285,11 +305,16 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
const [showSkillMenu, setShowSkillMenu] = useState(false);
|
||||
const [skillFilter, setSkillFilter] = useState("");
|
||||
const [highlightedSkillIndex, setHighlightedSkillIndex] = useState(0);
|
||||
const [mentionFilter, setMentionFilter] = useState("");
|
||||
const [mentionPopupVisible, setMentionPopupVisible] = useState(false);
|
||||
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
|
||||
const [mentionStartPos, setMentionStartPos] = useState(-1);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mentionCursorPosRef = useRef(0);
|
||||
const mode = useViewportMode();
|
||||
const isMobile = mode === "mobile";
|
||||
|
||||
@@ -301,10 +326,32 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
return matchingSkills.slice(0, 10);
|
||||
}, [discoveredSkills, skillFilter]);
|
||||
|
||||
const mentionAgents = useMemo(() => Array.from(agentsMap.values()), [agentsMap]);
|
||||
|
||||
const filteredMentionAgents = useMemo(() => {
|
||||
const normalizedFilter = mentionFilter.trim().toLowerCase();
|
||||
if (!normalizedFilter) {
|
||||
return mentionAgents;
|
||||
}
|
||||
return mentionAgents.filter((agent) => agent.name.toLowerCase().includes(normalizedFilter));
|
||||
}, [mentionAgents, mentionFilter]);
|
||||
|
||||
const mentionAgentsByName = useMemo(() => {
|
||||
const byName = new Map<string, Agent>();
|
||||
for (const agent of mentionAgents) {
|
||||
byName.set(agent.name.toLowerCase(), agent);
|
||||
}
|
||||
return byName;
|
||||
}, [mentionAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedSkillIndex(0);
|
||||
}, [filteredSkills]);
|
||||
|
||||
useEffect(() => {
|
||||
setMentionHighlightIndex(0);
|
||||
}, [mentionFilter, mentionPopupVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideSkillMenuTimeoutRef.current !== null) {
|
||||
@@ -391,6 +438,9 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
setMessageInput("");
|
||||
setShowSkillMenu(false);
|
||||
setSkillFilter("");
|
||||
setMentionPopupVisible(false);
|
||||
setMentionFilter("");
|
||||
setMentionStartPos(-1);
|
||||
try {
|
||||
await sendMessage(trimmed);
|
||||
} catch {
|
||||
@@ -427,9 +477,122 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMentionSelect = useCallback(
|
||||
(agent: Agent) => {
|
||||
const textarea = inputRef.current;
|
||||
if (!textarea || mentionStartPos < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionStart = textarea.selectionStart ?? mentionCursorPosRef.current;
|
||||
const selectionEnd = textarea.selectionEnd ?? selectionStart;
|
||||
const cursorPos = Math.max(selectionStart, selectionEnd);
|
||||
const safeStart = Math.min(mentionStartPos, cursorPos);
|
||||
const mentionText = `@${agent.name.replace(/\s+/g, "_")}`;
|
||||
const replacement = `${mentionText} `;
|
||||
const nextInput = messageInput.slice(0, safeStart) + replacement + messageInput.slice(cursorPos);
|
||||
const nextCursorPos = safeStart + replacement.length;
|
||||
|
||||
setMessageInput(nextInput);
|
||||
setMentionPopupVisible(false);
|
||||
setMentionFilter("");
|
||||
setMentionHighlightIndex(0);
|
||||
setMentionStartPos(-1);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!inputRef.current) return;
|
||||
inputRef.current.style.height = "auto";
|
||||
inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 120)}px`;
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setSelectionRange(nextCursorPos, nextCursorPos);
|
||||
});
|
||||
},
|
||||
[mentionStartPos, messageInput],
|
||||
);
|
||||
|
||||
const renderMessageContent = useCallback(
|
||||
(content: string) => {
|
||||
const mentionRegex = /@([\w-]+)/g;
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match = mentionRegex.exec(content);
|
||||
|
||||
while (match) {
|
||||
const [fullMatch, rawName = ""] = match;
|
||||
const start = match.index;
|
||||
if (start > lastIndex) {
|
||||
parts.push(content.slice(lastIndex, start));
|
||||
}
|
||||
|
||||
const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
|
||||
const mentionedAgent = mentionAgentsByName.get(normalizedName);
|
||||
if (mentionedAgent) {
|
||||
parts.push(
|
||||
<span key={`${mentionedAgent.id}-${start}`} className="chat-mention-chip">
|
||||
@{mentionedAgent.name.replace(/\s+/g, "_")}
|
||||
</span>,
|
||||
);
|
||||
} else {
|
||||
parts.push(fullMatch);
|
||||
}
|
||||
|
||||
lastIndex = start + fullMatch.length;
|
||||
match = mentionRegex.exec(content);
|
||||
}
|
||||
|
||||
if (lastIndex < content.length) {
|
||||
parts.push(content.slice(lastIndex));
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return parts;
|
||||
},
|
||||
[mentionAgentsByName],
|
||||
);
|
||||
|
||||
// Handle input key down
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
mentionCursorPosRef.current = e.currentTarget.selectionStart ?? mentionCursorPosRef.current;
|
||||
|
||||
if (mentionPopupVisible && e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (filteredMentionAgents.length > 0) {
|
||||
setMentionHighlightIndex((prev) => (prev + 1) % filteredMentionAgents.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mentionPopupVisible && e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (filteredMentionAgents.length > 0) {
|
||||
setMentionHighlightIndex((prev) =>
|
||||
prev === 0 ? filteredMentionAgents.length - 1 : prev - 1,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mentionPopupVisible && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const agentToSelect = filteredMentionAgents[mentionHighlightIndex] ?? filteredMentionAgents[0];
|
||||
if (agentToSelect) {
|
||||
handleMentionSelect(agentToSelect);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mentionPopupVisible && e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setMentionPopupVisible(false);
|
||||
setMentionFilter("");
|
||||
setMentionStartPos(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (showSkillMenu && e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (filteredSkills.length > 0) {
|
||||
@@ -468,27 +631,76 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
void handleSend();
|
||||
}
|
||||
},
|
||||
[showSkillMenu, filteredSkills, highlightedSkillIndex, handleSkillSelect, handleSend],
|
||||
[
|
||||
mentionPopupVisible,
|
||||
filteredMentionAgents,
|
||||
mentionHighlightIndex,
|
||||
handleMentionSelect,
|
||||
showSkillMenu,
|
||||
filteredSkills,
|
||||
highlightedSkillIndex,
|
||||
handleSkillSelect,
|
||||
handleSend,
|
||||
],
|
||||
);
|
||||
|
||||
const updateMentionState = useCallback((value: string, cursorPos: number) => {
|
||||
const mentionTriggerMatch = getMentionTriggerMatch(value, cursorPos);
|
||||
if (mentionTriggerMatch) {
|
||||
setMentionPopupVisible(true);
|
||||
setMentionFilter(mentionTriggerMatch.filter);
|
||||
setMentionStartPos(mentionTriggerMatch.start);
|
||||
return;
|
||||
}
|
||||
|
||||
setMentionPopupVisible(false);
|
||||
setMentionFilter("");
|
||||
setMentionStartPos(-1);
|
||||
}, []);
|
||||
|
||||
// Handle textarea resize
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const textarea = e.target;
|
||||
const nextValue = textarea.value;
|
||||
const cursorPos = textarea.selectionStart ?? nextValue.length;
|
||||
|
||||
mentionCursorPosRef.current = cursorPos;
|
||||
setMessageInput(nextValue);
|
||||
|
||||
const triggerMatch = getSkillTriggerMatch(nextValue);
|
||||
if (triggerMatch) {
|
||||
const skillTriggerMatch = getSkillTriggerMatch(nextValue);
|
||||
if (skillTriggerMatch) {
|
||||
setShowSkillMenu(true);
|
||||
setSkillFilter(triggerMatch.filter);
|
||||
setSkillFilter(skillTriggerMatch.filter);
|
||||
} else {
|
||||
setShowSkillMenu(false);
|
||||
setSkillFilter("");
|
||||
}
|
||||
|
||||
updateMentionState(nextValue, cursorPos);
|
||||
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
|
||||
}, []);
|
||||
}, [updateMentionState]);
|
||||
|
||||
const handleInputSelectionChange = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
||||
const textarea = e.currentTarget;
|
||||
const cursorPos = textarea.selectionStart ?? textarea.value.length;
|
||||
mentionCursorPosRef.current = cursorPos;
|
||||
updateMentionState(textarea.value, cursorPos);
|
||||
},
|
||||
[updateMentionState],
|
||||
);
|
||||
|
||||
const handleInputKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
return;
|
||||
}
|
||||
handleInputSelectionChange(e);
|
||||
},
|
||||
[handleInputSelectionChange],
|
||||
);
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
if (hideSkillMenuTimeoutRef.current !== null) {
|
||||
@@ -497,6 +709,9 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
|
||||
hideSkillMenuTimeoutRef.current = window.setTimeout(() => {
|
||||
setShowSkillMenu(false);
|
||||
setMentionPopupVisible(false);
|
||||
setMentionFilter("");
|
||||
setMentionStartPos(-1);
|
||||
hideSkillMenuTimeoutRef.current = null;
|
||||
}, 120);
|
||||
}, []);
|
||||
@@ -742,7 +957,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-message-content">{message.content}</div>
|
||||
<div className="chat-message-content">{renderMessageContent(message.content)}</div>
|
||||
{message.thinkingOutput && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>Thinking</summary>
|
||||
@@ -762,7 +977,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
return modelTag ? <span className="chat-model-tag">{modelTag}</span> : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="chat-message-content">{streamingText}</div>
|
||||
<div className="chat-message-content">{renderMessageContent(streamingText)}</div>
|
||||
{streamingThinking && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>Thinking</summary>
|
||||
@@ -813,19 +1028,31 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="chat-input-textarea"
|
||||
placeholder="Type a message..."
|
||||
value={messageInput}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
onFocus={handleInputFocus}
|
||||
disabled={isStreaming}
|
||||
rows={1}
|
||||
data-testid="chat-input"
|
||||
/>
|
||||
<div className="chat-input-wrapper">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="chat-input-textarea"
|
||||
placeholder="Type a message..."
|
||||
value={messageInput}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onKeyUp={handleInputKeyUp}
|
||||
onClick={handleInputSelectionChange}
|
||||
onBlur={handleInputBlur}
|
||||
onFocus={handleInputFocus}
|
||||
disabled={isStreaming}
|
||||
rows={1}
|
||||
data-testid="chat-input"
|
||||
/>
|
||||
<AgentMentionPopup
|
||||
agents={mentionAgents}
|
||||
filter={mentionFilter}
|
||||
highlightedIndex={mentionHighlightIndex}
|
||||
visible={mentionPopupVisible}
|
||||
onSelect={handleMentionSelect}
|
||||
position="below"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="chat-input-send"
|
||||
onClick={() => void handleSend()}
|
||||
|
||||
Reference in New Issue
Block a user