From 14a700842c3fa4309029b82f81143cbacdbb22ee Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Sun, 23 Aug 2026 04:51:07 +0000 Subject: [PATCH] FN-174: open new chats in offset windows Make Ctrl/Cmd+New Chat open a fresh conversation in an offset pop-out above the current chat. - Default new conversations to the pop-out window flow. - Cascade, focus, close, and host pop-out windows consistently. - Localize the new-chat action and document the interaction. - Add regression coverage for window behavior and chat state. Files changed: .changeset/fn-174-ctrl-new-chat-window.md | 7 ++ docs/dashboard-guide.md | 2 + packages/dashboard/app/components/ChatView.tsx | 45 +++++++++---- .../dashboard/app/components/FloatingWindow.tsx | 68 +++++++++++++++++--- packages/dashboard/app/components/PoppedOutChatWindows.tsx | 6 ++ .../__tests__/ChatView.new-chat-default.test.tsx | 19 ++++++ .../ChatView.pop-out-host-inventory.test.tsx | 1 + .../components/__tests__/FloatingWindow.test.tsx | 75 +++++++++++++++++++++- .../PoppedOutChatWindows.cascade.test.tsx | 69 ++++++++++++++++++++ .../app/hooks/__tests__/usePoppedOutChats.test.ts | 10 +-- packages/dashboard/app/hooks/useChat.ts | 23 +++++-- packages/dashboard/app/hooks/usePoppedOutChats.ts | 15 ++++- packages/i18n/locales/en/app.json | 1 + packages/i18n/locales/es/app.json | 3 +- packages/i18n/locales/fr/app.json | 1 + packages/i18n/locales/ko/app.json | 3 +- packages/i18n/locales/pt-BR/app.json | 3 +- packages/i18n/locales/zh-CN/app.json | 3 +- packages/i18n/locales/zh-TW/app.json | 3 +- packages/i18n/src/resources.d.ts | 1 + 20 files changed, 321 insertions(+), 37 deletions(-) Fusion-Task-Id: FN-174 Fusion-Task-Lineage: af8b67ff-2b9a-4d30-a84a-e81e2607cf7c Co-authored-by: Fusion --- .changeset/fn-174-ctrl-new-chat-window.md | 7 ++ docs/dashboard-guide.md | 2 + .../dashboard/app/components/ChatView.tsx | 45 ++++++++--- .../app/components/FloatingWindow.tsx | 68 +++++++++++++++-- .../app/components/PoppedOutChatWindows.tsx | 6 ++ .../ChatView.new-chat-default.test.tsx | 19 +++++ .../ChatView.pop-out-host-inventory.test.tsx | 1 + .../__tests__/FloatingWindow.test.tsx | 75 ++++++++++++++++++- .../PoppedOutChatWindows.cascade.test.tsx | 69 +++++++++++++++++ .../hooks/__tests__/usePoppedOutChats.test.ts | 10 ++- packages/dashboard/app/hooks/useChat.ts | 23 ++++-- .../dashboard/app/hooks/usePoppedOutChats.ts | 15 +++- packages/i18n/locales/en/app.json | 1 + packages/i18n/locales/es/app.json | 3 +- packages/i18n/locales/fr/app.json | 1 + packages/i18n/locales/ko/app.json | 3 +- packages/i18n/locales/pt-BR/app.json | 3 +- packages/i18n/locales/zh-CN/app.json | 3 +- packages/i18n/locales/zh-TW/app.json | 3 +- packages/i18n/src/resources.d.ts | 1 + 20 files changed, 321 insertions(+), 37 deletions(-) create mode 100644 .changeset/fn-174-ctrl-new-chat-window.md create mode 100644 packages/dashboard/app/components/__tests__/PoppedOutChatWindows.cascade.test.tsx diff --git a/.changeset/fn-174-ctrl-new-chat-window.md b/.changeset/fn-174-ctrl-new-chat-window.md new file mode 100644 index 0000000000..a92adb0f7d --- /dev/null +++ b/.changeset/fn-174-ctrl-new-chat-window.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Open a new chat in an offset in-app window with Ctrl/Cmd-click. +category: feature +dev: Adds FloatingWindow cascadeOffsetIndex, per-project usePoppedOutChats cascade slots, and createSession keepActiveSession. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 12d35f13ef..5a8a892ae5 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -756,6 +756,8 @@ The shared Chat header owns **New Chat** in both the list and selected detail ac For an active Direct conversation, open the row actions with desktop right-click or the **⋯** control (including touch, keyboard, compact, and dock hosts), then choose **Open in new window**. Fusion opens an independent in-app Quick Chat window in front of the chat window it was launched from and directly on that conversation's thread; it is not a browser or OS window. You can keep several different conversations open, move and close each one independently, and reopening the same conversation raises and reopens its existing window instead of duplicating it, without interrupting an in-flight reply. The in-window **< BACK** action returns to that window's conversation list. Rooms and archived conversations do not offer this action. Escape closes one secondary Quick Chat at a time after popped-out task windows and before the primary Quick Chat; switching projects or choosing all projects closes every secondary window. +On desktop, Ctrl-click (Windows/Linux) or Cmd-click (macOS) on **New Chat** creates the conversation in its own in-app chat window above the conversation you are reading. The host conversation, including an in-flight reply, remains uninterrupted; stacked chat windows are deliberately offset so they do not perfectly overlap. A plain **New Chat** click keeps the existing in-place behavior. Mobile and short-viewport hosts keep their full-screen sheet and plain creation behavior. + ### Conversation layout Use **Settings → Appearance → Conversation layout** to choose the project-scoped message presentation for every dashboard chat surface. **Bubbles** is the default and keeps the bounded, left/right-aligned message bubbles; **Full width** lets each message use the available transcript width. The choice applies immediately to normal Chat, Quick Chat, and dock/overflow Chat hosts, as well as task-detail **Activity** and task-aware **Chat**. Missing or invalid values safely use **Bubbles**. diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index d4726636f0..21fc2f4698 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -1363,34 +1363,51 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout // Handle create session const handleCreateSession = useCallback( - async (input: { agentId: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }) => { + async ( + input: { agentId: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }, + options?: { openInNewWindow?: boolean }, + ): Promise => { try { - await createSession(input); + if (options?.openInNewWindow) { + const session = await createSession(input, { keepActiveSession: true }); + onOpenSessionInNewWindow?.(session); + return session; + } + const session = await createSession(input); setDetailOpen(true); - return true; + return session; } catch { addToast(t("chat.failedToCreateSession", "Failed to create chat session"), "error"); - return false; + return null; } }, - [createSession, addToast, isChatMobile, t], + [addToast, createSession, onOpenSessionInNewWindow, t], ); - const handleNewChat = useCallback(() => { + /* + FNXC:ChatWindows 2026-08-23-04:29: + Ctrl/Cmd-click must create a conversation beside, never in place of, the conversation currently + open in this host. The plain path intentionally retains the existing in-place selection behavior. + */ + const handleNewChat = useCallback((event?: React.MouseEvent) => { + const openInNewWindow = Boolean((event?.ctrlKey || event?.metaKey) && onOpenSessionInNewWindow); if (chatDefaultTarget?.kind === "agent") { - void handleCreateSession({ agentId: chatDefaultTarget.agentId }); + const input = { agentId: chatDefaultTarget.agentId }; + void (openInNewWindow ? handleCreateSession(input, { openInNewWindow: true }) : handleCreateSession(input)); return; } if (chatDefaultTarget?.kind === "model") { - void handleCreateSession({ agentId: FN_AGENT_ID, modelProvider: chatDefaultTarget.modelProvider, modelId: chatDefaultTarget.modelId, thinkingLevel: chatDefaultTarget.thinkingLevel }); + const input = { agentId: FN_AGENT_ID, modelProvider: chatDefaultTarget.modelProvider, modelId: chatDefaultTarget.modelId, thinkingLevel: chatDefaultTarget.thinkingLevel }; + void (openInNewWindow ? handleCreateSession(input, { openInNewWindow: true }) : handleCreateSession(input)); return; } if (defaultModel.provider && defaultModel.modelId) { - void handleCreateSession({ agentId: FN_AGENT_ID, modelProvider: defaultModel.provider, modelId: defaultModel.modelId }); + const input = { agentId: FN_AGENT_ID, modelProvider: defaultModel.provider, modelId: defaultModel.modelId }; + void (openInNewWindow ? handleCreateSession(input, { openInNewWindow: true }) : handleCreateSession(input)); return; } addToast(t("chat.noDefaultModelConfigured", "Configure a default chat model in Settings before creating a conversation."), "error"); - }, [addToast, chatDefaultTarget, defaultModel, handleCreateSession, t]); + }, [addToast, chatDefaultTarget, defaultModel, handleCreateSession, onOpenSessionInNewWindow, t]); const resizeComposer = useCallback(() => { inputAutosizeRef.current?.resize(); @@ -2142,7 +2159,12 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout

{t("chat.startNewConversation", "Start a new conversation")}

- @@ -2836,6 +2858,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout className="btn btn-sm btn-primary chat-view-header-new-chat" onClick={handleNewChat} data-testid="chat-new-btn" + title={onOpenSessionInNewWindow ? t("chat.newChatOpenInNewWindowHint", "Ctrl/Cmd + click to open the new conversation in a separate window") : undefined} > {t("chat.newChat", "New Chat")} diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index b69a222e25..e411418646 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -52,6 +52,13 @@ export interface FloatingWindowProps { className?: string; /** Optional localStorage key used to restore the last clamped position and size. */ persistGeometryKey?: string; + /* + FNXC:FloatingWindow 2026-08-23-04:29: + Stacked callers can request a presentation-only cascade without changing the shared canonical + geometry. The persisted base remains un-cascaded, so reopening a window never walks it across + the viewport. + */ + cascadeOffsetIndex?: number; /** Skip desktop geometry restoration/writes while this caller renders as a full-screen mobile sheet. */ suspendGeometryPersistenceOnMobile?: boolean; /** Include the CSS short-viewport sheet breakpoint when suspending geometry persistence. */ @@ -109,6 +116,7 @@ Z-index now comes from the SHARED `floatingWindowStack` module (`nextFloatingZ`/ type ResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"]; export const FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT = "fusion:floating-window-geometry-change"; +export const FLOATING_WINDOW_CASCADE_STEP_PX = 28; /* FNXC:ModalTouchGeometry 2026-07-27-12:00: @@ -144,13 +152,30 @@ function clampPosition(position: FloatingWindowPosition, size: FloatingWindowSiz }; } +/** Resolve a clamped presentation offset while preserving the caller's canonical base geometry. */ +export function resolveFloatingWindowCascadeOffset( + base: FloatingWindowPosition, + size: FloatingWindowSize, + cascadeIndex: number, +): FloatingWindowPosition { + if (!Number.isFinite(cascadeIndex) || cascadeIndex <= 0) return { x: 0, y: 0 }; + + const distance = cascadeIndex * FLOATING_WINDOW_CASCADE_STEP_PX; + const forward = clampPosition({ x: base.x + distance, y: base.y + distance }, size); + const backward = clampPosition({ x: base.x - distance, y: base.y - distance }, size); + return { + x: forward.x === base.x ? backward.x - base.x : forward.x - base.x, + y: forward.y === base.y ? backward.y - base.y : forward.y - base.y, + }; +} + /* FNXC:FloatingWindow 2026-06-22-20:45: Default position cascades by windowKey so opening several windows in a row visibly offsets each one from a roughly-centered origin instead of stacking them pixel-perfect on top of one another. */ function defaultPositionFor(windowKey: string, size: FloatingWindowSize): FloatingWindowPosition { if (typeof window === "undefined") return { x: VIEWPORT_PADDING, y: VIEWPORT_PADDING }; - const cascade = cascadeIndexFor(windowKey) * 28; + const cascade = cascadeIndexFor(windowKey) * FLOATING_WINDOW_CASCADE_STEP_PX; return clampPosition( { x: (window.innerWidth - size.width) / 2 + cascade, y: (window.innerHeight - size.height) / 2 + cascade }, size @@ -203,6 +228,7 @@ export function FloatingWindow({ dragHandleSelector, className, persistGeometryKey, + cascadeOffsetIndex = 0, suspendGeometryPersistenceOnMobile = false, suspendGeometryPersistenceOnShortViewport = false, closeOnOutsidePointerDown = false, @@ -241,6 +267,7 @@ export function FloatingWindow({ */ const isTabletViewportMode = viewportMode === "tablet"; const initialGeometry = useRef<{ size: FloatingWindowSize; position: FloatingWindowPosition } | null>(null); + const cascadeOffsetRef = useRef({ x: 0, y: 0 }); /* FNXC:ModalGeometryPersistence 2026-07-16-00:40: Opt-in sheet callers leave desktop geometry untouched at `max-width: 768px`. Most wide, short @@ -251,19 +278,31 @@ export function FloatingWindow({ isFullScreenSheetViewport() || (suspendGeometryPersistenceOnShortViewport && isShortViewport()) ); + const applyCascadeOffset = (geometry: { size: FloatingWindowSize; position: FloatingWindowPosition }) => { + const offset = geometryPersistenceSuspended + ? { x: 0, y: 0 } + : resolveFloatingWindowCascadeOffset(geometry.position, geometry.size, cascadeOffsetIndex); + cascadeOffsetRef.current = offset; + return { + ...geometry, + position: { x: geometry.position.x + offset.x, y: geometry.position.y + offset.y }, + }; + }; + if (!initialGeometry.current) { const fallbackSize = clampSize(defaultSize ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }, resolvedMinSize); const fallbackPosition = defaultPosition ? clampPosition(defaultPosition, fallbackSize) : defaultPositionFor(windowKey, fallbackSize); - initialGeometry.current = geometryPersistenceSuspended + const baseGeometry = geometryPersistenceSuspended ? { size: fallbackSize, position: fallbackPosition } : readPersistedGeometry(persistGeometryKey, fallbackSize, fallbackPosition, resolvedMinSize); + initialGeometry.current = applyCascadeOffset(baseGeometry); } const [size, setSize] = useState(() => initialGeometry.current!.size ); const [position, setPosition] = useState(() => initialGeometry.current!.position); - const geometryIdentityRef = useRef({ windowKey, persistGeometryKey }); + const geometryIdentityRef = useRef({ windowKey, persistGeometryKey, cascadeOffsetIndex }); /* FNXC:ModalTouchGeometry 2026-07-27-20:00: @@ -273,18 +312,23 @@ export function FloatingWindow({ */ useLayoutEffect(() => { const previousIdentity = geometryIdentityRef.current; - if (previousIdentity.windowKey === windowKey && previousIdentity.persistGeometryKey === persistGeometryKey) return; + if ( + previousIdentity.windowKey === windowKey + && previousIdentity.persistGeometryKey === persistGeometryKey + && previousIdentity.cascadeOffsetIndex === cascadeOffsetIndex + ) return; const fallbackSize = clampSize(defaultSize ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }, resolvedMinSize); const fallbackPosition = defaultPosition ? clampPosition(defaultPosition, fallbackSize) : defaultPositionFor(windowKey, fallbackSize); - const nextGeometry = geometryPersistenceSuspended + const baseGeometry = geometryPersistenceSuspended ? { size: fallbackSize, position: fallbackPosition } : readPersistedGeometry(persistGeometryKey, fallbackSize, fallbackPosition, resolvedMinSize); - geometryIdentityRef.current = { windowKey, persistGeometryKey }; + const nextGeometry = applyCascadeOffset(baseGeometry); + geometryIdentityRef.current = { windowKey, persistGeometryKey, cascadeOffsetIndex }; initialGeometry.current = nextGeometry; setSize(nextGeometry.size); setPosition(nextGeometry.position); - }, [defaultPosition, defaultSize, geometryPersistenceSuspended, persistGeometryKey, resolvedMinSize, windowKey]); + }, [cascadeOffsetIndex, defaultPosition, defaultSize, geometryPersistenceSuspended, persistGeometryKey, resolvedMinSize, windowKey]); const claimFrontZ = useCallback(() => (layer === "task-detail" ? nextTaskDetailFloatingZ() : nextFloatingZ()), [layer]); const readCurrentZ = useCallback(() => (layer === "task-detail" ? currentTaskDetailFloatingZ() : currentFloatingZ()), [layer]); @@ -586,13 +630,21 @@ export function FloatingWindow({ }, [closeOnOutsidePointerDown, hidden, onClose]); /* + FNXC:FloatingWindow 2026-08-23-04:29: + Persist the un-cascaded base rather than a stacked presentation position. Shared chat windows can + then retain one stable geometry while each visible panel applies its own offset. + FNXC:ChatModal 2026-06-22-14:57: Quick Chat reopens should restore the last desktop floating-window size and position while still clamping onto the current viewport. Keep persistence generic and opt-in with persistGeometryKey so each caller controls whether geometry is shared or isolated. */ useEffect(() => { if (hidden || !persistGeometryKey || typeof window === "undefined" || geometryPersistenceSuspended) return; try { - localStorage.setItem(persistGeometryKey, JSON.stringify({ size, position })); + const canonicalPosition = clampPosition({ + x: position.x - cascadeOffsetRef.current.x, + y: position.y - cascadeOffsetRef.current.y, + }, size); + localStorage.setItem(persistGeometryKey, JSON.stringify({ size, position: canonicalPosition })); } catch { // Ignore storage failures; geometry persistence is a convenience only. } diff --git a/packages/dashboard/app/components/PoppedOutChatWindows.tsx b/packages/dashboard/app/components/PoppedOutChatWindows.tsx index 47425288d9..7c941ce919 100644 --- a/packages/dashboard/app/components/PoppedOutChatWindows.tsx +++ b/packages/dashboard/app/components/PoppedOutChatWindows.tsx @@ -23,6 +23,11 @@ export interface PoppedOutChatWindowsProps { } export function PoppedOutChatWindows({ entries, projectId, addToast, experimentalFeatures, onClose, onOpenSessionInNewWindow }: PoppedOutChatWindowsProps) { + /* + FNXC:ChatWindows 2026-08-23-04:29: + Slot zero still offsets by one step because Quick Chat owns the un-cascaded shared base. + This makes every secondary chat visibly sit above the surface beneath it. + */ return entries.filter((entry) => entry.projectId === projectId).map((entry) => ( { expect(createSession).toHaveBeenCalledWith({ agentId: "__fn_agent__", modelProvider: "openai", modelId: "gpt-4o" }); }); + it.each([{ ctrlKey: true }, { metaKey: true }])("opens Ctrl/Cmd New Chat without selecting the host session", async (modifier) => { + const session = makeSession({ id: "new-window-session" }); + const createSession = vi.fn().mockResolvedValue(session); + const onOpenSessionInNewWindow = vi.fn(); + const selectSession = vi.fn(); + mockUseChat.mockReturnValue(chatState({ createSession, selectSession })); + await renderWithAct(); + await waitForSettings(); + + fireEvent.click(screen.getByTestId("chat-new-btn"), modifier); + + await waitFor(() => expect(createSession).toHaveBeenCalledWith( + { agentId: "__fn_agent__", modelProvider: "openai", modelId: "gpt-4o" }, + { keepActiveSession: true }, + )); + expect(onOpenSessionInNewWindow).toHaveBeenCalledWith(session); + expect(selectSession).not.toHaveBeenCalled(); + }); + it("uses the same immediate creation path from the mobile host", async () => { mockMobileViewport(); const createSession = vi.fn(); diff --git a/packages/dashboard/app/components/__tests__/ChatView.pop-out-host-inventory.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.pop-out-host-inventory.test.tsx index 29724f785b..d76bd3309f 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.pop-out-host-inventory.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.pop-out-host-inventory.test.tsx @@ -29,6 +29,7 @@ describe("ChatView pop-out host inventory", () => { expect(popOut).toContain("initialDirectSession={entry.session}"); expect(popOut).toContain("initialDirectSessionNonce={entry.focusNonce}"); expect(popOut).toContain("raiseToFrontSignal={entry.focusNonce}"); + expect(popOut).toContain("cascadeOffsetIndex={entry.cascadeSlot + 1}"); const affordanceFiles = [...walk(appRoot)].filter((file) => readFileSync(file, "utf8").includes("chat-context-open-window")); expect(affordanceFiles.map((file) => relative(appRoot, file).replaceAll("\\", "/"))).toEqual(["components/ChatView.tsx"]); }); diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx index 8e1b711358..9bb329a715 100644 --- a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx +++ b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx @@ -3,7 +3,12 @@ import { resolve } from "node:path"; import { render, screen, fireEvent } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loadAllAppCss, loadStylesCss } from "../../test/cssFixture"; -import { FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, FloatingWindow } from "../FloatingWindow"; +import { + FLOATING_WINDOW_CASCADE_STEP_PX, + FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, + FloatingWindow, + resolveFloatingWindowCascadeOffset, +} from "../FloatingWindow"; import { readAppFile } from "../../test/cssFixture"; import { dragWithTouch, expectFloatingWindowStructure, resizeWithTouch } from "./floatingWindowMigration.test-helpers"; @@ -1071,6 +1076,74 @@ describe("FloatingWindow", () => { expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual(geometry); }); + it("resolves cascade offsets without moving the canonical geometry", () => { + expect(resolveFloatingWindowCascadeOffset({ x: 100, y: 100 }, { width: 600, height: 400 }, 0)).toEqual({ x: 0, y: 0 }); + expect(resolveFloatingWindowCascadeOffset({ x: 100, y: 100 }, { width: 600, height: 400 }, 2)).toEqual({ + x: FLOATING_WINDOW_CASCADE_STEP_PX * 2, + y: FLOATING_WINDOW_CASCADE_STEP_PX * 2, + }); + expect(resolveFloatingWindowCascadeOffset({ x: 408, y: 192 }, { width: 600, height: 560 }, 1)).toEqual({ + x: -FLOATING_WINDOW_CASCADE_STEP_PX, + y: -FLOATING_WINDOW_CASCADE_STEP_PX, + }); + }); + + it("cascades persisted shared geometry while retaining its canonical base", () => { + const key = "floating-window:cascade"; + const baseGeometry = { size: { width: 600, height: 400 }, position: { x: 120, y: 96 } }; + localStorage.setItem(key, JSON.stringify(baseGeometry)); + + const { unmount } = render( + <> + {}} persistGeometryKey={key}>
+ {}} persistGeometryKey={key} cascadeOffsetIndex={1}>
+ , + ); + + expect(screen.getByTestId("floating-window-cascade-base").style.left).toBe("120px"); + expect(screen.getByTestId("floating-window-cascade-base").style.top).toBe("96px"); + expect(screen.getByTestId("floating-window-cascade-offset").style.left).toBe(`${120 + FLOATING_WINDOW_CASCADE_STEP_PX}px`); + expect(screen.getByTestId("floating-window-cascade-offset").style.top).toBe(`${96 + FLOATING_WINDOW_CASCADE_STEP_PX}px`); + expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual(baseGeometry); + unmount(); + + render( {}} persistGeometryKey={key} cascadeOffsetIndex={1}>
); + expect(screen.getByTestId("floating-window-cascade-offset-remount").style.left).toBe(`${120 + FLOATING_WINDOW_CASCADE_STEP_PX}px`); + expect(screen.getByTestId("floating-window-cascade-offset-remount").style.top).toBe(`${96 + FLOATING_WINDOW_CASCADE_STEP_PX}px`); + }); + + it("flips a cascade toward the viewport when its base is pinned at the far edge", () => { + const key = "floating-window:cascade-edge"; + localStorage.setItem(key, JSON.stringify({ size: { width: 600, height: 560 }, position: { x: 408, y: 192 } })); + + render( {}} persistGeometryKey={key} cascadeOffsetIndex={1}>
); + + expect(screen.getByTestId("floating-window-cascade-edge").style.left).toBe(`${408 - FLOATING_WINDOW_CASCADE_STEP_PX}px`); + expect(screen.getByTestId("floating-window-cascade-edge").style.top).toBe(`${192 - FLOATING_WINDOW_CASCADE_STEP_PX}px`); + }); + + it("suppresses cascade offsets in a full-screen sheet", () => { + const key = "floating-window:cascade-sheet"; + localStorage.setItem(key, JSON.stringify({ size: { width: 600, height: 400 }, position: { x: 120, y: 96 } })); + setSheetViewport(true); + + render( + {}} + persistGeometryKey={key} + cascadeOffsetIndex={1} + suspendGeometryPersistenceOnMobile + defaultPosition={{ x: 32, y: 48 }} + >
, + ); + + const panel = screen.getByTestId("floating-window-cascade-sheet"); + expect(panel.style.left).toBe("32px"); + expect(panel.style.top).toBe("48px"); + }); + it("shares geometry only between windows that opt into the same persistence key", () => { localStorage.setItem( "floating-window:shared-task-detail", diff --git a/packages/dashboard/app/components/__tests__/PoppedOutChatWindows.cascade.test.tsx b/packages/dashboard/app/components/__tests__/PoppedOutChatWindows.cascade.test.tsx new file mode 100644 index 0000000000..37d047a87a --- /dev/null +++ b/packages/dashboard/app/components/__tests__/PoppedOutChatWindows.cascade.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { FLOATING_WINDOW_CASCADE_STEP_PX } from "../FloatingWindow"; +import { PoppedOutChatWindows } from "../PoppedOutChatWindows"; + +vi.mock("../ChatView", () => ({ + ChatView: ({ initialDirectSession }: { initialDirectSession: { id: string } }) =>
{initialDirectSession.id}
, +})); + +const entry = (id: string, cascadeSlot: number, projectId = "project-a") => ({ + projectId, + session: { + id, + agentId: "agent-1", + title: id, + status: "active" as const, + createdAt: "2026-08-23T00:00:00.000Z", + updatedAt: "2026-08-23T00:00:00.000Z", + }, + focusNonce: 1, + cascadeSlot, +}); + +describe("PoppedOutChatWindows cascade", () => { + beforeEach(() => { + localStorage.clear(); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1600 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 1000 }); + }); + + it("offsets stacked chat windows from their shared persisted geometry", () => { + localStorage.setItem("kb-dashboard-chat-floating-window", JSON.stringify({ + size: { width: 900, height: 620 }, position: { x: 120, y: 96 }, + })); + render( + , + ); + + const first = screen.getByTestId("floating-window-chat-window-project-a-first"); + const second = screen.getByTestId("floating-window-chat-window-project-a-second"); + expect(Number.parseFloat(second.style.left) - Number.parseFloat(first.style.left)).toBe(FLOATING_WINDOW_CASCADE_STEP_PX); + expect(Number.parseFloat(second.style.top) - Number.parseFloat(first.style.top)).toBe(FLOATING_WINDOW_CASCADE_STEP_PX); + expect(second.style.left).not.toBe(first.style.left); + expect(second.style.top).not.toBe(first.style.top); + }); + + it("filters another project while preserving the surviving slot offset", () => { + localStorage.setItem("kb-dashboard-chat-floating-window", JSON.stringify({ + size: { width: 900, height: 620 }, position: { x: 120, y: 96 }, + })); + render( + , + ); + expect(screen.queryByTestId("floating-window-chat-window-project-b-hidden")).toBeNull(); + expect(screen.getByTestId("floating-window-chat-window-project-a-visible").style.left).toBe(`${120 + FLOATING_WINDOW_CASCADE_STEP_PX * 2}px`); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/usePoppedOutChats.test.ts b/packages/dashboard/app/hooks/__tests__/usePoppedOutChats.test.ts index 55e8fc2598..717d286f4a 100644 --- a/packages/dashboard/app/hooks/__tests__/usePoppedOutChats.test.ts +++ b/packages/dashboard/app/hooks/__tests__/usePoppedOutChats.test.ts @@ -18,7 +18,7 @@ describe("usePoppedOutChats", () => { act(() => result.current.popOut("project-a", session("a", "refreshed"))); expect(result.current.entries).toHaveLength(2); expect(result.current.entries.map((entry) => entry.session.id)).toEqual(["a", "b"]); - expect(result.current.entries[0]).toMatchObject({ session: { title: "refreshed" }, focusNonce: firstNonce + 1 }); + expect(result.current.entries[0]).toMatchObject({ session: { title: "refreshed" }, focusNonce: firstNonce + 1, cascadeSlot: 0 }); expect(result.current.entries[1].focusNonce).toBe(1); }); @@ -29,10 +29,12 @@ describe("usePoppedOutChats", () => { act(() => result.current.popOut("project-b", session("same"))); act(() => result.current.popOut("project-a", session("same", "raised"))); - expect(result.current.entries.map((entry) => [entry.projectId, entry.session.id, entry.focusNonce])) - .toEqual([["project-a", "same", 2], ["project-a", "other", 1], ["project-b", "same", 1]]); + expect(result.current.entries.map((entry) => [entry.projectId, entry.session.id, entry.focusNonce, entry.cascadeSlot])) + .toEqual([["project-a", "same", 2, 0], ["project-a", "other", 1, 1], ["project-b", "same", 1, 0]]); act(() => result.current.close("project-a", "other")); - expect(result.current.entries.map((entry) => entry.session.id)).toEqual(["same", "same"]); + act(() => result.current.popOut("project-a", session("replacement"))); + expect(result.current.entries.find((entry) => entry.session.id === "replacement")?.cascadeSlot).toBe(1); + expect(result.current.entries.map((entry) => entry.session.id)).toEqual(["same", "same", "replacement"]); act(() => result.current.closeAll()); expect(result.current.entries).toEqual([]); }); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index dfcd9a46e3..5f21e846fc 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -142,8 +142,14 @@ export interface UseChatReturn { // Session operations selectSession: (id: string, sessionOverride?: ChatSessionInfo) => void; + /** + * FNXC:ChatWindows 2026-08-23-04:29: + * A modifier-click opens a new conversation par dessus the current host without interrupting an + * in-flight reply. `keepActiveSession` inserts the session but deliberately avoids selecting it. + */ createSession: ( input: { agentId: string; title?: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }, + options?: { keepActiveSession?: boolean }, ) => Promise; archiveSession: (id: string) => Promise; archivedSessions: ChatSessionInfo[]; @@ -1227,15 +1233,18 @@ export function useChat( // Create a new session const createSession = useCallback( - async (input: { agentId: string; title?: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }) => { + async ( + input: { agentId: string; title?: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }, + options?: { keepActiveSession?: boolean }, + ) => { const previousSessionId = activeSessionRef.current?.id; const data = await apiCreateChatSession(input, projectId); - if (streamRef.current) { + if (!options?.keepActiveSession && streamRef.current) { streamRef.current.close(); streamRef.current = null; } - lastAttachedGenerationRef.current = null; + if (!options?.keepActiveSession) lastAttachedGenerationRef.current = null; const newSession: ChatSessionInfo = { id: data.session.id, title: data.session.title, @@ -1254,9 +1263,11 @@ export function useChat( return sortChatSessions([newSession, ...prev]); }); - removePersistedPendingChatMessages(previousSessionId); - resetTransientComposerState(); - selectSession(newSession.id, newSession); + if (!options?.keepActiveSession) { + removePersistedPendingChatMessages(previousSessionId); + resetTransientComposerState(); + selectSession(newSession.id, newSession); + } return newSession; }, diff --git a/packages/dashboard/app/hooks/usePoppedOutChats.ts b/packages/dashboard/app/hooks/usePoppedOutChats.ts index 2677c9a5c5..bfd641831e 100644 --- a/packages/dashboard/app/hooks/usePoppedOutChats.ts +++ b/packages/dashboard/app/hooks/usePoppedOutChats.ts @@ -15,6 +15,8 @@ export interface PoppedOutChatEntry { session: ChatSessionInfo; /** Increments for every open request so an in-place window can reclaim its stack position. */ focusNonce: number; + /** Stable per-project cascade slot used to visibly separate stacked chat windows. */ + cascadeSlot: number; } export interface UsePoppedOutChatsResult { @@ -30,10 +32,19 @@ export function usePoppedOutChats(): UsePoppedOutChatsResult { const popOut = useCallback((projectId: string, session: ChatSessionInfo) => { setEntries((current) => { const index = current.findIndex((entry) => entry.projectId === projectId && entry.session.id === session.id); - if (index === -1) return [...current, { projectId, session, focusNonce: 1 }]; + /* + FNXC:ChatWindows 2026-08-23-04:29: + Allocate the first available slot within a project so secondary chat windows cascade predictably. + Refreshing retains its slot and closing releases it for the next conversation. + */ + if (index === -1) { + const occupiedSlots = new Set(current.filter((entry) => entry.projectId === projectId).map((entry) => entry.cascadeSlot)); + const cascadeSlot = Array.from({ length: current.length + 1 }, (_, slot) => slot).find((slot) => !occupiedSlots.has(slot)) ?? current.length; + return [...current, { projectId, session, focusNonce: 1, cascadeSlot }]; + } const refreshed = [...current]; const previous = refreshed[index]; - refreshed[index] = { projectId, session, focusNonce: previous.focusNonce + 1 }; + refreshed[index] = { projectId, session, focusNonce: previous.focusNonce + 1, cascadeSlot: previous.cascadeSlot }; return refreshed; }); }, []); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 6b91cf631f..b5c77c9b5e 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -1314,6 +1314,7 @@ "modeAgent": "Agent", "modeModel": "Model", "newChat": "New Chat", + "newChatOpenInNewWindowHint": "Ctrl/Cmd + click to open the new conversation in a separate window", "newChatModeAgent": "Agent", "newChatModeModel": "Model", "newChatTitle": "New Chat", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 5c892a7730..87eb1a3b26 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -1421,7 +1421,8 @@ "restore": "", "quoteMessage": "Citar mensaje", "agentRepliesFailed": "Algunos agentes no pudieron responder: {{agents}}", - "noDefaultModelConfigured": "Configura un modelo de chat predeterminado en Ajustes antes de crear una conversación." + "noDefaultModelConfigured": "Configura un modelo de chat predeterminado en Ajustes antes de crear una conversación.", + "newChatOpenInNewWindowHint": "" }, "chatRooms": { "error": { diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 083ca9f1ee..6ea6556f2b 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -1300,6 +1300,7 @@ "modeAgent": "Agent", "modeModel": "Modèle", "newChat": "Nouvelle discussion", + "newChatOpenInNewWindowHint": "Ctrl/Cmd + clic pour ouvrir la nouvelle conversation dans une fenêtre séparée", "newChatModeAgent": "Agent", "newChatModeModel": "Modèle", "newChatTitle": "Nouvelle discussion", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 523b3f47f0..a743c6aaf0 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -1421,7 +1421,8 @@ "restore": "", "quoteMessage": "메시지 인용", "agentRepliesFailed": "일부 에이전트가 응답하지 못했습니다: {{agents}}", - "noDefaultModelConfigured": "대화를 만들기 전에 설정에서 기본 채팅 모델을 구성하세요." + "noDefaultModelConfigured": "대화를 만들기 전에 설정에서 기본 채팅 모델을 구성하세요.", + "newChatOpenInNewWindowHint": "" }, "chatRooms": { "error": { diff --git a/packages/i18n/locales/pt-BR/app.json b/packages/i18n/locales/pt-BR/app.json index fc104bbd64..600a3432e9 100644 --- a/packages/i18n/locales/pt-BR/app.json +++ b/packages/i18n/locales/pt-BR/app.json @@ -1431,7 +1431,8 @@ "restore": "", "quoteMessage": "Citar mensagem", "agentRepliesFailed": "Alguns agentes não puderam responder: {{agents}}", - "noDefaultModelConfigured": "Configure um modelo de chat padrão nas Configurações antes de criar uma conversa." + "noDefaultModelConfigured": "Configure um modelo de chat padrão nas Configurações antes de criar uma conversa.", + "newChatOpenInNewWindowHint": "" }, "chatRooms": { "error": { diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index c86dbe8516..6b170f18ec 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -1421,7 +1421,8 @@ "restore": "", "quoteMessage": "引用消息", "agentRepliesFailed": "部分代理无法回复:{{agents}}", - "noDefaultModelConfigured": "请先在设置中配置默认聊天模型,再创建对话。" + "noDefaultModelConfigured": "请先在设置中配置默认聊天模型,再创建对话。", + "newChatOpenInNewWindowHint": "" }, "chatRooms": { "error": { diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index fe54e9d70a..bbfe66fc03 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -1421,7 +1421,8 @@ "restore": "", "quoteMessage": "引用訊息", "agentRepliesFailed": "部分代理無法回覆:{{agents}}", - "noDefaultModelConfigured": "請先在設定中設定預設聊天模型,再建立對話。" + "noDefaultModelConfigured": "請先在設定中設定預設聊天模型,再建立對話。", + "newChatOpenInNewWindowHint": "" }, "chatRooms": { "error": { diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index 09a44d523b..f957f98679 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -1326,6 +1326,7 @@ export default interface Resources { "newChat": "New Chat", "newChatModeAgent": "Agent", "newChatModeModel": "Model", + "newChatOpenInNewWindowHint": "Ctrl/Cmd + click to open the new conversation in a separate window", "newChatTitle": "New Chat", "newNotAllowedForTaskChat": "This chat is tied to a task — /new and /clear can't clear it", "noAgentsAvailable": "No agents available",