FN-8248: fix Quick Chat popup stacking

Make Quick Chat and task popups honor their most recent interaction order.

- Place Quick Chat in the task-detail peer stack while retaining higher utility windows.
- Raise Create Room dialogs above Chat on each open, including mobile sheets.
- Add stacking regression coverage and operator documentation with screenshots.

Files changed:
 docs/assets/fn-8248-chat-above-task.png            | Bin 0 -> 55108 bytes
 docs/assets/fn-8248-mobile-create-room.png         | Bin 0 -> 56699 bytes
 docs/assets/fn-8248-task-above-chat.png            | Bin 0 -> 74263 bytes
 docs/dashboard-guide.md                            |   5 +-
 packages/dashboard/app/App.tsx                     |  13 +++-
 .../dashboard/app/components/CreateRoomModal.tsx   |  15 ++++-
 .../dashboard/app/components/FloatingWindow.tsx    |  11 +++-
 .../components/__tests__/CreateRoomModal.test.tsx  |  72 +++++++++++++++++++++
 .../FloatingWindowStack.cross-type.test.tsx        |  37 +++++++----
 .../app/components/floatingWindowStack.ts          |  10 +--
 10 files changed, 139 insertions(+), 24 deletions(-)

Fusion-Task-Id: FN-8248

Fusion-Task-Lineage: 94ef20f1-96da-4249-9812-169f780c9451

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 16:34:16 -07:00
parent 703488f0f3
commit eee04bc1a8
10 changed files with 139 additions and 24 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -127,9 +127,9 @@ If **Settings → Appearance → Open tasks in the right sidebar** is enabled, b
<!-- FNXC:MobileTaskPopups 2026-07-13-00:00 (FN-7945): Board-card and List row/card task opens have a separate default-off popup setting so operators on desktop, tablet, and mobile can opt into the existing FloatingWindow task popup when they want the board or List view visible; ordinary popup routing takes precedence over the right dock, while deep-tab, context-menu, and non-board/List opens keep their existing paths.
FNXC:TaskPopupGeometry 2026-07-03-00:00: Desktop/tablet task popups share a persisted geometry key across task IDs so operators can size and place the popup once, then open other tasks without repeating that setup. Mobile remains a full-screen sheet regardless of saved desktop geometry.
FNXC:RightDockTaskPopup 2026-07-03-00:00: The same task-popup preference also applies to ordinary clicks in the right-dock Tasks list so that opted-in operators never lose the list to embedded detail by clicking a dock task row.
FNXC:TaskPopupLayer 2026-07-04-18:36: Ordinary task popups sit on the board/task-detail layer instead of the global floating-utility stack so the board/list context remains visually primary while Terminal, Quick Chat, file browser, workflow editor, and other utility windows keep their top utility stacking.
FNXC:TaskPopupLayer 2026-07-17-15:55: Task popups and Quick Chat share the board/task-detail interaction stack, so the most recently clicked or focused overlapping surface appears on top. Terminal, Files, New Task, workflow editor, and other utility windows retain their separate higher utility stacking.
FNXC:TaskPopupViewGating 2026-07-15-15:20: FN-8016 makes popup view scoping default-on for every dashboard view. Hidden popups are render-only hidden, not closed, so snapshots and shared geometry survive navigation; same-task popups remain independently addressable by origin view. -->
**Settings → Appearance → Open tasks as popups** changes ordinary board task-card clicks, List row/card opens, and right-dock Tasks-list clicks across desktop, tablet, and mobile viewports. When enabled, those clicks use the existing task popup/FloatingWindow surface on the board/task-detail layer instead of the full-panel task detail, List split-detail/docked detail, or right-dock task detail, keeping the board, List view, or dock list visible in the background while utility windows keep their higher global stacking. On desktop and tablet, task popups restore the last saved popup size and position between tasks; on mobile, task popups stay full-screen sheets. Deep `changes`/`retries`/`workflow` opens, List context-menu/refine actions, task-detail links, plugin/graph opens, and explicit pop-out actions keep their existing paths.
**Settings → Appearance → Open tasks as popups** changes ordinary board task-card clicks, List row/card opens, and right-dock Tasks-list clicks across desktop, tablet, and mobile viewports. When enabled, those clicks use the existing task popup/FloatingWindow surface on the board/task-detail layer instead of the full-panel task detail, List split-detail/docked detail, or right-dock task detail, keeping the board, List view, or dock list visible in the background. Overlapping Quick Chat and task popups interleave by the most-recent pointer or focus interaction; other utility windows retain their higher global stacking. On desktop and tablet, task popups restore the last saved popup size and position between tasks; on mobile, task popups stay full-screen sheets. Deep `changes`/`retries`/`workflow` opens, List context-menu/refine actions, task-detail links, plugin/graph opens, and explicit pop-out actions keep their existing paths.
**Settings → Appearance → Keep task popups on the view where they were opened** is enabled by default. Every task-detail popup is attached to its exact originating view, including Planning, Agents, Command Center, Documents, Missions, and plugin views: navigating elsewhere hides it without closing it, and returning re-shows it in the same saved position. You can open the same task independently in more than one view; closing or pressing Escape on one popup does not affect the other. Disable this setting only to restore legacy globally shared popups. Legacy saved popups without an origin remain visible everywhere for compatibility.
@@ -669,6 +669,7 @@ Quick Chat is an optional fast, project-scoped assistant surface for conversatio
- On first open for a project, Quick Chat restores the last opened non-archived session from per-project local storage; if that saved session is missing, it falls back to the most recently touched non-archived session by latest activity (`max(lastMessageAt, updatedAt)`), and only falls back to the first agent / configured default model when no prior session exists.
- Closing and reopening Quick Chat keeps the active conversation warm in memory, so messages stay visible without a conversation reload or "Loading conversation…" flash.
- Clicking outside the desktop Quick Chat floating window closes it by default; disable **Settings → General → Close Quick Chat on outside click** to keep it open until you explicitly close/minimize/maximize it. Model, thinking-level, agent, dependency, node, and priority dropdowns that open from Quick Chat are treated as part of the panel even when they render in a page-level portal, so selecting from them does not close Quick Chat. Task pop-out floating windows remain persistent on page clicks.
- Quick Chat and task popups use an interaction-driven peer stack: clicking or focusing either overlapping surface raises it above the other. Blocking dialogs opened from Quick Chat, including **Create Room**, claim a fresh top layer on every open so they remain above both the desktop floating panel and the mobile full-screen sheet.
- Queued follow-up messages entered while a Quick Chat response is still streaming now persist per session, so closing/reopening the panel restores the queued stack and flushes the messages one at a time in FIFO order as active responses complete.
- 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

View File

@@ -1838,6 +1838,13 @@ function AppInner() {
hideHeader
dragHandleSelector=".chat-view--floating .view-header"
className="floating-window--chat"
layer="task-detail"
/*
FNXC:ChatModal 2026-07-17-15:55:
Quick Chat and task-detail popups must share the task popup interaction stack: either
overlapping surface claims the front on pointer/focus. Other utility FloatingWindows keep
their higher utility band, so this scoped opt-in cannot change Terminal, Files, or New Task.
*/
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Chat is a full-screen sheet at ≤768px, so preserve its desktop location and size instead of restoring or overwriting them there. */
suspendGeometryPersistenceOnMobile
persistGeometryKey="kb-dashboard-chat-floating-window"
@@ -1874,8 +1881,10 @@ function AppInner() {
FNXC:TaskPopupGeometry 2026-07-03-00:00:
Every task-detail FloatingWindow keeps its per-task windowKey for DOM identity, dedupe, cascade fallback, and z-index independence, but all task-detail popups share one persisted geometry key so operators do not resize or reposition the popup between tasks.
FNXC:TaskPopupLayer 2026-07-04-18:36:
Ordinary task-detail popups belong to the board/task-detail layer, not the global floating-utility stack. Pass the task-detail layer so board/right-dock task opens preserve the visible board context while utility windows keep the higher app-wide raise/focus contract.
FNXC:TaskPopupLayer 2026-07-17-15:55:
Task-detail popups and Quick Chat share the task-detail interaction stack, so pointer/focus
moves either overlapping surface above the other. Other utility windows retain their separate,
higher utility band and cannot be reordered by task-popup interaction.
FNXC:TaskPopupViewGating 2026-07-15-15:20:
Rendering uses only the active view's scoped entries; state keeps hidden snapshots so returning remounts them with shared geometry. Each FloatingWindow key includes its origin so identical task ids never collide across views.

View File

@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { fetchAgents } from "../api";
import type { Agent } from "@fusion/core";
import { AgentAvatar } from "./AgentAvatar";
import { LoadingSpinner } from "./LoadingSpinner";
import { nextFloatingZ } from "./floatingWindowStack";
import "./CreateRoomModal.css";
export interface RoomDraft {
@@ -50,6 +51,16 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
const [isSubmitting, setIsSubmitting] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
/*
FNXC:ChatRoomModal 2026-07-17-15:56:
Create Room is a blocking dialog launched from Quick Chat's non-blocking FloatingWindow. Because
both surfaces portal to body, claim a fresh shared top-layer z-index on every open so the dialog
stays above its parent on desktop and the mobile full-screen Chat sheet, including after reopen.
*/
const [overlayZ, setOverlayZ] = useState<number | undefined>(undefined);
useLayoutEffect(() => {
if (isOpen) setOverlayZ(nextFloatingZ());
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
@@ -140,7 +151,7 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
};
return createPortal(
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()}>
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()} style={overlayZ ? { zIndex: overlayZ } : undefined}>
<div className="modal modal-lg create-room-modal" role="dialog" aria-modal="true" aria-label={t("createRoom.title", "Create room")} onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>{t("createRoom.title", "Create room")}</h3>

View File

@@ -58,7 +58,10 @@ export interface FloatingWindowProps {
* Persistent task/terminal pop-outs must omit this so page clicks do not close them.
*/
closeOnOutsidePointerDown?: boolean;
/** Layer band for z-index claiming. Task details stay with the board/task-detail surface; utilities use the global floating stack. */
/**
* Layer band for z-index claiming. Task-detail peers (including Quick Chat) interleave by
* interaction; unrelated utilities use the global floating stack.
*/
layer?: "utility" | "task-detail";
// FNXC:FloatingWindow 2026-07-11-11:30: accessible name for the dialog overlay so headerless windows (e.g. artifact viewers with their own header chrome) stay queryable/announcable by label.
ariaLabel?: string;
@@ -213,8 +216,10 @@ export function FloatingWindow({
const claimFrontZ = useCallback(() => (layer === "task-detail" ? nextTaskDetailFloatingZ() : nextFloatingZ()), [layer]);
const readCurrentZ = useCallback(() => (layer === "task-detail" ? currentTaskDetailFloatingZ() : currentFloatingZ()), [layer]);
/*
FNXC:TaskPopupLayer 2026-07-04-18:36:
Task-detail popups intentionally claim the lower board/task-detail band, while utility FloatingWindow callers keep the higher global stack. This preserves raise/focus among multiple task popups without making ordinary board popups cover utility surfaces like Terminal or Quick Chat.
FNXC:TaskPopupLayer 2026-07-17-15:55:
Task-detail popups and Quick Chat intentionally claim the same board/task-detail interaction
band, so either may rise above the other on pointer/focus. Other utility FloatingWindow callers
retain the higher global stack; only Chat opts into this task-popup peer contract.
*/
const [zIndex, setZIndex] = useState<number>(() => claimFrontZ());
const panelRef = useRef<HTMLDivElement | null>(null);

View File

@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { userEvent } from "@testing-library/user-event";
import { CreateRoomModal, validateRoomName } from "../CreateRoomModal";
import { FloatingWindow } from "../FloatingWindow";
import * as apiModule from "../../api";
vi.mock("../../api", () => ({
@@ -72,6 +73,77 @@ describe("CreateRoomModal", () => {
expect(onCreate).toHaveBeenCalledWith({ name: "engineering", displayName: "#engineering", memberAgentIds: ["agent-1"] });
});
it("claims a fresh top layer above floating Chat on open and reopen", async () => {
const { rerender } = render(
<>
<FloatingWindow windowKey="chat-modal" title="Chat" onClose={() => {}} layer="task-detail" className="floating-window--chat">
<div>floating chat representative</div>
</FloatingWindow>
<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />
</>,
);
const chatPanel = screen.getByTestId("floating-window-chat-modal");
const firstOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement;
expect(Number(firstOverlay.style.zIndex)).toBeGreaterThan(Number(chatPanel.style.zIndex));
await screen.findByRole("button", { name: /Alpha/i });
// Another Chat interaction can claim its peer stack while the dialog is closed.
rerender(
<>
<FloatingWindow windowKey="chat-modal" title="Chat" onClose={() => {}} layer="task-detail" className="floating-window--chat">
<div>floating chat representative</div>
</FloatingWindow>
<CreateRoomModal isOpen={false} onClose={vi.fn()} onCreate={vi.fn()} />
</>,
);
fireEvent.pointerDown(chatPanel);
rerender(
<>
<FloatingWindow windowKey="chat-modal" title="Chat" onClose={() => {}} layer="task-detail" className="floating-window--chat">
<div>floating chat representative</div>
</FloatingWindow>
<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />
</>,
);
const reopenedOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement;
expect(Number(reopenedOverlay.style.zIndex)).toBeGreaterThan(Number(chatPanel.style.zIndex));
expect(Number(reopenedOverlay.style.zIndex)).toBeGreaterThan(Number(firstOverlay.style.zIndex));
});
it("stays above floating Chat while agent data is loading or empty", async () => {
mockFetchAgents.mockImplementation(() => new Promise(() => {}));
const loading = render(
<>
<FloatingWindow windowKey="chat-loading" title="Chat" onClose={() => {}} layer="task-detail" className="floating-window--chat">
<div>floating chat representative</div>
</FloatingWindow>
<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />
</>,
);
const loadingOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement;
expect(screen.getByRole("status")).toHaveTextContent("Loading agents...");
expect(Number(loadingOverlay.style.zIndex)).toBeGreaterThan(Number(screen.getByTestId("floating-window-chat-loading").style.zIndex));
loading.unmount();
mockFetchAgents.mockResolvedValueOnce([]);
render(
<>
<FloatingWindow windowKey="chat-empty" title="Chat" onClose={() => {}} layer="task-detail" className="floating-window--chat">
<div>floating chat representative</div>
</FloatingWindow>
<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />
</>,
);
const emptyOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement;
expect(await screen.findByText("No agents in this project yet.")).toBeInTheDocument();
expect(Number(emptyOverlay.style.zIndex)).toBeGreaterThan(Number(screen.getByTestId("floating-window-chat-empty").style.zIndex));
});
it("closes on escape and overlay click", async () => {
const onClose = vi.fn();
render(<CreateRoomModal isOpen onClose={onClose} onCreate={vi.fn()} />);

View File

@@ -8,8 +8,9 @@ import { currentFloatingZ, currentTaskDetailFloatingZ, nextFloatingZ, nextTaskDe
FNXC:FloatingWindow 2026-06-22-21:30:
Cross-type shared-stack contract. Utility floating modal types (utility FloatingWindow, the right-dock pop-out, the floating terminal, the floating New Task dialog) draw their z-index from the SINGLE module-level utility counter so tapping ANY utility raises it above ALL other utilities REGARDLESS of type. RightDockExpandModal stands in for the three non-FloatingWindow floating modals (terminal + New Task wire the identical claim-on-mount + bring-to-front-on-pointerdown pattern; they are heavier to mount in JSDOM and assert the same inline-zIndex contract).
FNXC:TaskPopupLayer 2026-07-04-18:36:
Task-detail FloatingWindow callers are excluded from the global utility stack. They use a lower board/task-detail counter so ordinary board/right-dock task popups can raise among themselves without becoming topmost utility overlays.
FNXC:TaskPopupLayer 2026-07-17-15:55:
Quick Chat opts into the task-detail counter so task popups and Chat interleave by interaction.
Unrelated utility windows remain excluded and keep their independent, higher utility stack.
*/
const renderProps = { addToast: () => {}, projectId: "project-1" } as const;
@@ -59,26 +60,40 @@ describe("floatingWindowStack (cross-type)", () => {
expect(Number(dockPanel.style.zIndex)).toBeGreaterThan(Number(fwPanel.style.zIndex));
});
it("keeps task-detail FloatingWindow popups out of the global utility band", () => {
it("interleaves Quick Chat with one or multiple task popups while utility windows stay higher", () => {
render(
<>
<FloatingWindow windowKey="task" title="Task" onClose={() => {}} layer="task-detail" className="floating-window--task-detail">
<div>task body</div>
<FloatingWindow windowKey="chat-modal" title="Chat" onClose={() => {}} layer="task-detail" className="floating-window--chat">
<div>chat body</div>
</FloatingWindow>
<FloatingWindow windowKey="task-a" title="Task A" onClose={() => {}} layer="task-detail" className="floating-window--task-detail">
<div>task a body</div>
</FloatingWindow>
<FloatingWindow windowKey="task-b" title="Task B" onClose={() => {}} layer="task-detail" className="floating-window--task-detail">
<div>task b body</div>
</FloatingWindow>
<RightDockExpandModal viewKey="files" renderProps={renderProps} onClose={() => {}} />
</>,
);
const taskPanel = screen.getByTestId("floating-window-task");
const taskOverlay = screen.getByTestId("floating-window-overlay-task");
const chatPanel = screen.getByTestId("floating-window-chat-modal");
const taskA = screen.getByTestId("floating-window-task-a");
const taskB = screen.getByTestId("floating-window-task-b");
const dockPanel = screen
.getByTestId("right-dock-expand-modal")
.querySelector(".right-dock-expand-modal--floating") as HTMLElement;
expect(Number(taskPanel.style.zIndex)).toBeLessThan(Number(dockPanel.style.zIndex));
expect(Number(taskOverlay.style.zIndex)).toBeLessThan(Number(dockPanel.style.zIndex));
// Later task mounts above Chat; utility surfaces still retain their higher independent band.
expect(Number(taskB.style.zIndex)).toBeGreaterThan(Number(chatPanel.style.zIndex));
expect(Number(dockPanel.style.zIndex)).toBeGreaterThan(Number(taskB.style.zIndex));
fireEvent.pointerDown(taskPanel);
expect(Number(taskPanel.style.zIndex)).toBeLessThan(Number(dockPanel.style.zIndex));
// Task and Chat both claim the same peer counter in either interaction direction.
fireEvent.pointerDown(chatPanel);
expect(Number(chatPanel.style.zIndex)).toBeGreaterThan(Number(taskB.style.zIndex));
fireEvent.focus(taskA);
expect(Number(taskA.style.zIndex)).toBeGreaterThan(Number(chatPanel.style.zIndex));
fireEvent.focus(chatPanel);
expect(Number(chatPanel.style.zIndex)).toBeGreaterThan(Number(taskA.style.zIndex));
expect(Number(chatPanel.style.zIndex)).toBeLessThan(Number(dockPanel.style.zIndex));
});
});

View File

@@ -5,8 +5,10 @@ SHARED floating-utility z-index stack. This is the ONE source of z-index for uti
FNXC:FloatingWindow 2026-06-22-22:30:
Base band sits at 10100+ — ABOVE the page overlay/popover band (log viewer, workflow-editor modal, selection popover, static fullscreen fallbacks at z 10000-10001) so a utility floating window the user is dragging is never painted over by those. Transient top-right toasts are bumped to 10500 (styles.css) so system feedback still shows above a dragged utility window. The workflow prompt fullscreen overlay is itself a floating utility surface and claims `nextFloatingZ()` when opened, because a static z 10000 fallback is hidden by the workflow editor's full-screen mobile FloatingWindow sheet. The counter is module-level and intentionally monotonic: it only ever climbs, which is fine for a session-length dashboard. All floating overlays are `pointer-events: none` (click-through) so raising panels into this shared band never traps clicks on the page behind them. CRITICAL: every floating modal must be portaled to document.body so this shared z is compared in ONE root stacking context (an inline panel cannot beat siblings outside its own context no matter its z).
FNXC:TaskPopupLayer 2026-07-04-18:36:
Task-detail popups are ordinary board/task-detail surfaces, not global utilities. Keep their focus stack in a lower board-layer band so task popups can raise among themselves without covering terminal, right-dock expand, Quick Chat, file browser, workflow editor, or other utility windows that intentionally use the 10100+ band.
FNXC:TaskPopupLayer 2026-07-17-15:55:
Task-detail popups and Quick Chat are interaction-stack peers in this lower board-layer band: the
most recently mounted or pointer/focus-interacted peer is on top. Terminal, right-dock expand,
Files, New Task, and other utility surfaces continue to use the separate 10100+ utility band.
*/
let topZ = 10100;
let taskDetailTopZ = 220;
@@ -21,12 +23,12 @@ export function currentFloatingZ(): number {
return topZ;
}
/** Claim the front of the board/task-detail popup stack. Monotonic, session-length. */
/** Claim the front of the task-popup peer stack (task details and Quick Chat). Monotonic, session-length. */
export function nextTaskDetailFloatingZ(): number {
return ++taskDetailTopZ;
}
/** Current top of the board/task-detail popup stack (read-only). */
/** Current top of the task-popup peer stack (read-only). */
export function currentTaskDetailFloatingZ(): number {
return taskDetailTopZ;
}