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 <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-174-ctrl-new-chat-window.md
Normal file
7
.changeset/fn-174-ctrl-new-chat-window.md
Normal file
@@ -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.
|
||||
@@ -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**.
|
||||
|
||||
@@ -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<ChatSessionInfo | null> => {
|
||||
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<HTMLButtonElement>) => {
|
||||
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
|
||||
<div className="chat-empty-state">
|
||||
<MessageSquare size={48} strokeWidth={1.5} />
|
||||
<h2>{t("chat.startNewConversation", "Start a new conversation")}</h2>
|
||||
<button className="btn btn-primary" onClick={handleNewChat}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleNewChat}
|
||||
data-testid="chat-new-btn-empty"
|
||||
title={onOpenSessionInNewWindow ? t("chat.newChatOpenInNewWindowHint", "Ctrl/Cmd + click to open the new conversation in a separate window") : undefined}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t("chat.newChat", "New Chat")}
|
||||
</button>
|
||||
@@ -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}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("chat.newChat", "New Chat")}
|
||||
|
||||
@@ -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<FloatingWindowPosition>({ 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<FloatingWindowSize>(() =>
|
||||
initialGeometry.current!.size
|
||||
);
|
||||
const [position, setPosition] = useState<FloatingWindowPosition>(() => 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.
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
<FloatingWindow
|
||||
key={`${entry.projectId}:${entry.session.id}`}
|
||||
@@ -36,6 +41,7 @@ export function PoppedOutChatWindows({ entries, projectId, addToast, experimenta
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
persistGeometryKey="kb-dashboard-chat-floating-window"
|
||||
cascadeOffsetIndex={entry.cascadeSlot + 1}
|
||||
defaultSize={{ width: 980, height: 680 }}
|
||||
minSize={{ width: 300, height: 420 }}
|
||||
ariaLabel={entry.session.title || "Chat"}
|
||||
|
||||
@@ -234,6 +234,25 @@ describe("ChatView New Chat project default behavior", () => {
|
||||
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(<ChatView projectId="project-a" addToast={vi.fn()} onOpenSessionInNewWindow={onOpenSessionInNewWindow} />);
|
||||
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();
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<>
|
||||
<FloatingWindow windowKey="cascade-base" title="Base" onClose={() => {}} persistGeometryKey={key}><div /></FloatingWindow>
|
||||
<FloatingWindow windowKey="cascade-offset" title="Offset" onClose={() => {}} persistGeometryKey={key} cascadeOffsetIndex={1}><div /></FloatingWindow>
|
||||
</>,
|
||||
);
|
||||
|
||||
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(<FloatingWindow windowKey="cascade-offset-remount" title="Offset" onClose={() => {}} persistGeometryKey={key} cascadeOffsetIndex={1}><div /></FloatingWindow>);
|
||||
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(<FloatingWindow windowKey="cascade-edge" title="Edge" onClose={() => {}} persistGeometryKey={key} cascadeOffsetIndex={1}><div /></FloatingWindow>);
|
||||
|
||||
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(
|
||||
<FloatingWindow
|
||||
windowKey="cascade-sheet"
|
||||
title="Sheet"
|
||||
onClose={() => {}}
|
||||
persistGeometryKey={key}
|
||||
cascadeOffsetIndex={1}
|
||||
suspendGeometryPersistenceOnMobile
|
||||
defaultPosition={{ x: 32, y: 48 }}
|
||||
><div /></FloatingWindow>,
|
||||
);
|
||||
|
||||
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",
|
||||
|
||||
@@ -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 } }) => <div>{initialDirectSession.id}</div>,
|
||||
}));
|
||||
|
||||
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(
|
||||
<PoppedOutChatWindows
|
||||
entries={[entry("first", 0), entry("second", 1)]}
|
||||
projectId="project-a"
|
||||
addToast={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onOpenSessionInNewWindow={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PoppedOutChatWindows
|
||||
entries={[entry("visible", 1), entry("hidden", 0, "project-b")]}
|
||||
projectId="project-a"
|
||||
addToast={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onOpenSessionInNewWindow={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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`);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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<ChatSessionInfo>;
|
||||
archiveSession: (id: string) => Promise<void>;
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1421,7 +1421,8 @@
|
||||
"restore": "",
|
||||
"quoteMessage": "메시지 인용",
|
||||
"agentRepliesFailed": "일부 에이전트가 응답하지 못했습니다: {{agents}}",
|
||||
"noDefaultModelConfigured": "대화를 만들기 전에 설정에서 기본 채팅 모델을 구성하세요."
|
||||
"noDefaultModelConfigured": "대화를 만들기 전에 설정에서 기본 채팅 모델을 구성하세요.",
|
||||
"newChatOpenInNewWindowHint": ""
|
||||
},
|
||||
"chatRooms": {
|
||||
"error": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1421,7 +1421,8 @@
|
||||
"restore": "",
|
||||
"quoteMessage": "引用消息",
|
||||
"agentRepliesFailed": "部分代理无法回复:{{agents}}",
|
||||
"noDefaultModelConfigured": "请先在设置中配置默认聊天模型,再创建对话。"
|
||||
"noDefaultModelConfigured": "请先在设置中配置默认聊天模型,再创建对话。",
|
||||
"newChatOpenInNewWindowHint": ""
|
||||
},
|
||||
"chatRooms": {
|
||||
"error": {
|
||||
|
||||
@@ -1421,7 +1421,8 @@
|
||||
"restore": "",
|
||||
"quoteMessage": "引用訊息",
|
||||
"agentRepliesFailed": "部分代理無法回覆:{{agents}}",
|
||||
"noDefaultModelConfigured": "請先在設定中設定預設聊天模型,再建立對話。"
|
||||
"noDefaultModelConfigured": "請先在設定中設定預設聊天模型,再建立對話。",
|
||||
"newChatOpenInNewWindowHint": ""
|
||||
},
|
||||
"chatRooms": {
|
||||
"error": {
|
||||
|
||||
1
packages/i18n/src/resources.d.ts
vendored
1
packages/i18n/src/resources.d.ts
vendored
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user