feat(FN-3152): add Quick Chat FAB with slash-triggered menu, resizable chat

Merged branch consolidates the FN-3119 Quick Chat FAB with slash-triggered skill menu, the FN-3152 resizable chat sidebar, planning comment inputs for mission and milestone interview modals (FN-3139), and removes the legacy agent tree view (useAgentHierarchy hook and AgentsView tree styling). Also i

Fusion-Task-Id: FN-3152
This commit is contained in:
Fusion
2026-05-02 00:45:53 -07:00
committed by gsxdsm
parent 8dadbd6dc2
commit 9ad83d021d
3 changed files with 236 additions and 3 deletions

View File

@@ -269,6 +269,10 @@ const chatMarkdownComponents: Components = {
* of the agentId stored on the session. This ID serves as metadata only.
*/
const FN_AGENT_ID = "__fn_agent__";
const CHAT_SIDEBAR_DEFAULT_WIDTH = 280;
const CHAT_SIDEBAR_MIN_WIDTH = 180;
const CHAT_SIDEBAR_MAX_WIDTH = 500;
const CHAT_SIDEBAR_STORAGE_KEY = "fusion:chat-sidebar-width";
interface PendingAttachment {
file: File;
@@ -714,6 +718,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [sidebarVisible, setSidebarVisible] = useState(true);
const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH);
const [agentsMap, setAgentsMap] = useState<Map<string, Agent>>(new Map());
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkill[]>([]);
const [skillsLoading, setSkillsLoading] = useState(true);
@@ -761,6 +766,20 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const mentionCursorPosRef = useRef(0);
const mode = useViewportMode();
const isMobile = mode === "mobile";
useEffect(() => {
try {
const rawWidth = localStorage.getItem(CHAT_SIDEBAR_STORAGE_KEY);
if (!rawWidth) return;
const parsedWidth = Number.parseInt(rawWidth, 10);
if (Number.isNaN(parsedWidth)) return;
const clampedWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, parsedWidth));
setSidebarWidth(clampedWidth);
} catch {
// Ignore storage errors.
}
}, []);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: isMobile && !!activeSession,
});
@@ -1358,6 +1377,74 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
[deleteSession, addToast],
);
const persistSidebarWidth = useCallback((width: number) => {
try {
localStorage.setItem(CHAT_SIDEBAR_STORAGE_KEY, String(width));
} catch {
// Ignore storage errors.
}
}, []);
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (isMobile) {
return;
}
event.preventDefault();
event.stopPropagation();
const resizeHandle = event.currentTarget;
if (typeof resizeHandle.setPointerCapture === "function") {
resizeHandle.setPointerCapture(event.pointerId);
}
const startX = event.clientX;
const startWidth = sidebarWidth;
let latestWidth = startWidth;
document.body.style.userSelect = "none";
const onPointerMove = (moveEvent: PointerEvent) => {
const deltaX = moveEvent.clientX - startX;
const nextWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, startWidth + deltaX));
latestWidth = nextWidth;
setSidebarWidth(nextWidth);
persistSidebarWidth(nextWidth);
};
const onPointerUp = (upEvent: PointerEvent) => {
if (typeof resizeHandle.releasePointerCapture === "function") {
resizeHandle.releasePointerCapture(upEvent.pointerId);
}
document.body.style.userSelect = "";
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
persistSidebarWidth(latestWidth);
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
}, [isMobile, persistSidebarWidth, sidebarWidth]);
const handleResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (isMobile) {
return;
}
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
return;
}
event.preventDefault();
const step = event.shiftKey ? 50 : 10;
const delta = event.key === "ArrowLeft" ? -step : step;
const nextWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, sidebarWidth + delta));
setSidebarWidth(nextWidth);
persistSidebarWidth(nextWidth);
}, [isMobile, persistSidebarWidth, sidebarWidth]);
// Handle session click
const handleSessionClick = useCallback(
(id: string) => {
@@ -1440,7 +1527,10 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
return (
<div className="chat-view">
{/* Sidebar */}
<div className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}>
<div
className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
>
{/* Search section */}
<div className="chat-sidebar-search">
<div className="chat-sidebar-search-wrapper">
@@ -1518,6 +1608,21 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
</div>
</div>
{!isMobile && sidebarVisible && (
<div
className="chat-sidebar-resize-handle"
role="separator"
aria-orientation="vertical"
aria-valuemin={CHAT_SIDEBAR_MIN_WIDTH}
aria-valuemax={CHAT_SIDEBAR_MAX_WIDTH}
aria-valuenow={sidebarWidth}
aria-label="Resize chat sidebar"
tabIndex={0}
onPointerDown={handleResizeStart}
onKeyDown={handleResizeKeyDown}
/>
)}
{/* Context Menu */}
{contextMenu && (
<div