FN-116: open multiple Quick Chats from conversations

Add in-app Quick Chat windows that open exact Direct conversation snapshots without disturbing the source chat.

- Add pop-out window state, rendering, and close controls for multiple Quick Chats.
- Add context-menu launching and isolated session/preferences handling.
- Add localized labels, dashboard documentation, regression tests, and a release changeset.

Files changed:
 .changeset/fn-116-multiple-quick-chats.md          |  7 +++
 docs/dashboard-guide.md                            |  2 +
 packages/dashboard/app/App.tsx                     | 34 ++++++++++++--
 packages/dashboard/app/components/ChatView.tsx     | 36 +++++++++++++--
 .../app/components/PoppedOutChatWindows.tsx        | 53 ++++++++++++++++++++++
 .../__tests__/ChatView.core-contracts.test.tsx     | 15 ++++++
 .../__tests__/PoppedOutChatWindows.test.tsx        | 25 +++++++++
 .../app/components/dashboard/MainContent.tsx       |  2 +
 .../dashboard/app/components/dashboard/types.ts    |  2 +
 .../app/components/overflowViewRegistry.tsx        |  3 ++
 .../app/components/useRightDockController.tsx      |  3 ++
 .../app/hooks/__tests__/usePoppedOutChats.test.ts  | 24 +++++++++
 packages/dashboard/app/hooks/useChat.ts            | 42 ++++++++++++++--
 packages/dashboard/app/hooks/usePoppedOutChats.ts  | 42 +++++++++++++++++
 packages/i18n/locales/en/app.json                  |  1 +
 packages/i18n/locales/es/app.json                  |  1 +
 packages/i18n/locales/fr/app.json                  |  1 +
 packages/i18n/locales/ko/app.json                  |  1 +
 packages/i18n/locales/pt-BR/app.json               |  1 +
 packages/i18n/locales/zh-CN/app.json               |  1 +
 packages/i18n/locales/zh-TW/app.json               |  1 +
 packages/i18n/src/resources.d.ts                   | 38 ++++++++++++++---
 22 files changed, 313 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-116

Fusion-Task-Lineage: 486e3092-c4db-4e50-a9df-f2adfeb6fddf

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-21 18:49:53 +00:00
parent 2430ce69b0
commit c47d555123
22 changed files with 313 additions and 22 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Open multiple conversations in independent Quick Chat windows.
category: feature
dev: Adds project-scoped, in-memory Direct-chat pop-outs with local session preferences.

View File

@@ -744,6 +744,8 @@ Chat view provides project-scoped conversations with agents. Every host—embedd
The shared Chat header owns **New Chat** in both the list and selected detail across embedded Chat, Quick Chat, mobile, and dock hosts. Search and tag filters, archived/restore, and each row's rename, pin, archive, and delete actions remain list-only. Detail intentionally contains the saved conversation title, secondary model metadata when available, and its one **< BACK** action: it does not include a conversation selector or duplicate management controls. The default list contains only active sessions; use **Archived conversations** to view archived sessions, restore one to the active list, or explicitly delete it. Archive is the default removal action, while delete remains a separate destructive action.
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 for that conversation; 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 refreshes its existing window instead of duplicating it. 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.
### 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**.

View File

@@ -8,6 +8,7 @@ import {
import { Header, useViewportMode } from "./components/Header";
import { TaskDetailContent } from "./components/TaskDetailModal";
import { FloatingWindow } from "./components/FloatingWindow";
import { PoppedOutChatWindows } from "./components/PoppedOutChatWindows";
import { AppModals } from "./components/AppModals";
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
import { TopProgressBar } from "./components/TopProgressBar";
@@ -86,6 +87,7 @@ import { useCapacityRiskBanner } from "./hooks/useCapacityRiskBanner";
import { useMainPanelTaskDetail } from "./hooks/useMainPanelTaskDetail";
import { useBoardScrollRestore } from "./hooks/useBoardScrollRestore";
import { usePoppedOutTasks, type PoppedOutTaskEntry } from "./hooks/usePoppedOutTasks";
import { usePoppedOutChats, type PoppedOutChatEntry } from "./hooks/usePoppedOutChats";
import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal";
import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager";
import { ShellConnectionStatus } from "./components/ShellConnectionStatus";
@@ -219,6 +221,7 @@ export function getBoardTaskOpenRoute(options: {
export interface DashboardShortcutPopupState {
poppedOutTaskEntries: Array<Pick<PoppedOutTaskEntry, "task" | "originTaskView">>;
poppedOutChatEntries: Array<Pick<PoppedOutChatEntry, "projectId" | "session">>;
quickChatOpen: boolean;
terminalOpen: boolean;
modalClosers: Array<[boolean, () => void]>;
@@ -226,6 +229,7 @@ export interface DashboardShortcutPopupState {
export interface DashboardShortcutPopupHandlers {
closePoppedOutTask: (taskId: string, originTaskView?: TaskView) => void;
closePoppedOutChat: (projectId: string, sessionId: string) => void;
closeQuickChat: () => void;
closeTerminal: () => void;
}
@@ -260,6 +264,11 @@ export function closeTopmostDashboardPopupForShortcut(
handlers.closePoppedOutTask(lastPoppedOutTask.task.id, lastPoppedOutTask.originTaskView);
return true;
}
const lastPoppedOutChat = state.poppedOutChatEntries[state.poppedOutChatEntries.length - 1];
if (lastPoppedOutChat) {
handlers.closePoppedOutChat(lastPoppedOutChat.projectId, lastPoppedOutChat.session.id);
return true;
}
if (state.quickChatOpen) {
handlers.closeQuickChat();
return true;
@@ -609,6 +618,7 @@ function AppInner() {
FN-8016 identifies a popped-out task detail by task id plus origin view. The same task can therefore coexist in separate view-scoped FloatingWindows while re-opening it on one view refreshes only that entry.
*/
const { entries: poppedOutTaskEntries, popOut: popOutTaskDetail, close: closePoppedOutTask, closeAll: closeAllPoppedOutTasks } = usePoppedOutTasks();
const { entries: poppedOutChatEntries, popOut: popOutChat, close: closePoppedOutChat, closeAll: closeAllPoppedOutChats } = usePoppedOutChats();
const popupNavCloseRef = useRef(new Map<string, () => void>());
/*
@@ -689,6 +699,9 @@ function AppInner() {
}, [initialLoadComplete]);
const [quickChatOpen, setQuickChatOpen] = useState(false);
const openSessionInNewWindow = useCallback((session: import("./hooks/useChat").ChatSessionInfo) => {
if (currentProject?.id) popOutChat(currentProject.id, session);
}, [currentProject?.id, popOutChat]);
const [chatComposerPrefill, setChatComposerPrefill] = useState<{ text: string; nonce: number } | null>(null);
const [quickChatEverOpenedProjectId, setQuickChatEverOpenedProjectId] = useState<string | null>(null);
const quickChatProjectIdRef = useRef<string | undefined>(undefined);
@@ -1324,6 +1337,7 @@ function AppInner() {
return closeTopmostDashboardPopupForShortcut(
{
poppedOutTaskEntries: visiblePoppedOutTaskEntries,
poppedOutChatEntries,
quickChatOpen,
terminalOpen: modalManager.terminalOpen,
modalClosers: [
@@ -1347,11 +1361,12 @@ function AppInner() {
},
{
closePoppedOutTask: closePoppedOutTaskWithNav,
closePoppedOutChat,
closeQuickChat: () => setQuickChatOpen(false),
closeTerminal: closeTerminalWithNav,
},
);
}, [closePoppedOutTaskWithNav, closeTerminalWithNav, modalManager, quickChatOpen, visiblePoppedOutTaskEntries]);
}, [closePoppedOutChat, closePoppedOutTaskWithNav, closeTerminalWithNav, modalManager, poppedOutChatEntries, quickChatOpen, visiblePoppedOutTaskEntries]);
const openFilesWithNav = useCallback((workspace?: string, initialFile?: string | null) => {
modalManager.openFiles(workspace, initialFile);
@@ -1577,7 +1592,7 @@ function AppInner() {
// Props for the extracted <MainContent> switch (see components/dashboard/MainContent.tsx).
// Every value is passed by its App name; the switch renders the same subtrees as before.
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, columnFlagsByTaskId: footerColumnFlagsByTaskId, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openTaskPopup: popOutTaskDetailForCurrentView, openMobileTasksInPopup, openFileInBrowser, onMoveTask: moveTask, onDeleteTask: deleteTask, onArchiveTask: archiveTask, onRevertTask: revertTask, onMergeTask: mergeTask, onRetryTask: retryTask, onPauseTask: pauseTask, onUnpauseTask: unpauseTask, onBypassReview: bypassReview, onResetTask: resetTask, onDuplicateTask: duplicateTask, onTaskUpdated: (task: Task) => ingestCreatedTasks([task]), openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, taskDetailChatFirst, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, columnFlagsByTaskId: footerColumnFlagsByTaskId, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openTaskPopup: popOutTaskDetailForCurrentView, onOpenSessionInNewWindow: openSessionInNewWindow, openMobileTasksInPopup, openFileInBrowser, onMoveTask: moveTask, onDeleteTask: deleteTask, onArchiveTask: archiveTask, onRevertTask: revertTask, onMergeTask: mergeTask, onRetryTask: retryTask, onPauseTask: pauseTask, onUnpauseTask: unpauseTask, onBypassReview: bypassReview, onResetTask: resetTask, onDuplicateTask: duplicateTask, onTaskUpdated: (task: Task) => ingestCreatedTasks([task]), openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, taskDetailChatFirst, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
/*
FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
@@ -1625,6 +1640,7 @@ function AppInner() {
closeProjectScopedUiRef.current = () => {
modalManager.closeProjectScopedModals();
closeAllPoppedOutTasks();
closeAllPoppedOutChats();
if (mainPanelDetailTask) {
closeTaskDetailMainPanel();
}
@@ -1702,6 +1718,7 @@ function AppInner() {
skillsEnabled,
experimentalFeatures,
setQuickChatOpen,
onOpenSessionInNewWindow: openSessionInNewWindow,
chatComposerPrefill,
mailComposerPrefill,
onSendAsReport: handleSendChatMessageAsReport,
@@ -2132,7 +2149,7 @@ function AppInner() {
hidden={!quickChatOpen}
title="Chat"
onClose={() => setQuickChatOpen(false)}
closeOnOutsidePointerDown={quickChatCloseOnOutsideClick}
closeOnOutsidePointerDown={poppedOutChatEntries.length === 0 && quickChatCloseOnOutsideClick}
hideHeader
dragHandleSelector=".chat-view--floating .view-header"
className="floating-window--chat"
@@ -2169,6 +2186,7 @@ function AppInner() {
initialComposerDraft={chatComposerPrefill?.text}
initialComposerDraftNonce={chatComposerPrefill?.nonce}
onSendAsReport={handleSendChatMessageAsReport}
onOpenSessionInNewWindow={openSessionInNewWindow}
onMaximize={() => {
handleTaskViewChange("chat");
setQuickChatOpen(false);
@@ -2178,6 +2196,16 @@ function AppInner() {
</Suspense>
</FloatingWindow>
)}
{currentProject ? (
<PoppedOutChatWindows
entries={poppedOutChatEntries}
projectId={currentProject.id}
addToast={addToast}
experimentalFeatures={experimentalFeatures}
onClose={closePoppedOutChat}
onOpenSessionInNewWindow={openSessionInNewWindow}
/>
) : null}
{/*
FNXC:FloatingWindow 2026-06-22-20:45:
One movable, resizable, non-blocking FloatingWindow per popped-out task. Each hosts the same embedded TaskDetailContent List/Board use, wired to the same App task handlers. Live row preferred by id; falls back to the snapshot. Terminal/destructive actions and the window close button both remove the entry. Multiple entries → multiple coexisting windows; FloatingWindow's per-window z-counter handles focus-to-front so the clicked one comes on top.

View File

@@ -20,10 +20,11 @@ import {
Pin,
PinOff,
MoreHorizontal,
ExternalLink,
Tag,
FileText,
} from "lucide-react";
import { FN_AGENT_ID, TASK_PLANNER_CHAT_AGENT_ID_PREFIX, useChat, type ChatMessageInfo } from "../hooks/useChat";
import { FN_AGENT_ID, TASK_PLANNER_CHAT_AGENT_ID_PREFIX, useChat, type ChatMessageInfo, type ChatSessionInfo } from "../hooks/useChat";
import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms";
import { useChatUnread } from "../hooks/useChatUnread";
import { useComposerDictation } from "../hooks/useComposerDictation";
@@ -120,6 +121,11 @@ export interface ChatViewProps {
onPopOut?: () => void;
onMaximize?: () => void;
onClose?: () => void;
/** Opens this exact active Direct session in a separate in-app Quick Chat. */
onOpenSessionInNewWindow?: (session: ChatSessionInfo) => void;
/** Secondary windows start in Direct and keep selection/scope storage private. */
initialDirectSession?: ChatSessionInfo;
persistChatPreferences?: boolean;
/** Optional external composer seed; paired with a nonce so repeated opens reseed intentionally. */
initialComposerDraft?: string;
initialComposerDraftNonce?: number;
@@ -557,7 +563,7 @@ interface RoomContext {
memberIds: ReadonlySet<string>;
}
export function ChatView({ projectId, addToast, floating = false, compactLayout = false, findActive = true, onPopOut, onMaximize, onClose, chatCommandContext, initialComposerDraft, initialComposerDraftNonce, onSendAsReport }: ChatViewProps) {
export function ChatView({ projectId, addToast, floating = false, compactLayout = false, findActive = true, onPopOut, onMaximize, onClose, onOpenSessionInNewWindow, initialDirectSession, persistChatPreferences = true, chatCommandContext, initialComposerDraft, initialComposerDraftNonce, onSendAsReport }: ChatViewProps) {
const { t } = useTranslation("app");
const chatMessageLayout = useChatMessageLayout();
useEffect(() => {
@@ -670,12 +676,13 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
setSearchQuery,
filteredSessions,
agentsMap: chatAgentsMap,
} = useChat(projectId, addToast);
} = useChat(projectId, addToast, { initialSession: initialDirectSession, persistActiveSession: persistChatPreferences });
const [showNewDialog, setShowNewDialog] = useState(false);
/* FNXC:ChatRooms 2026-06-23-01:28: Chat Rooms graduated from Experimental; stale false flags should not hide rooms in the main view, popout modal, or quick-chat surfaces. */
const chatRoomsEnabled = true;
const [chatScope, setChatScope] = useState<"direct" | "rooms">(() => {
if (!persistChatPreferences || initialDirectSession) return "direct";
try {
const persistedScope = localStorage.getItem(CHAT_SCOPE_STORAGE_KEY);
if (persistedScope === "rooms" && chatRoomsEnabled) {
@@ -933,6 +940,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
useEffect(() => {
if (!persistChatPreferences || initialDirectSession) {
setChatScope("direct");
return;
}
try {
const persistedScope = localStorage.getItem(CHAT_SCOPE_STORAGE_KEY);
if (persistedScope === "direct") {
@@ -945,19 +956,20 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
} catch {
// Ignore storage errors.
}
}, [chatRoomsEnabled]);
}, [chatRoomsEnabled, initialDirectSession, persistChatPreferences]);
useEffect(() => {
if (!chatRoomsEnabled && chatScope === "rooms") {
setChatScope("direct");
return;
}
if (!persistChatPreferences) return;
try {
localStorage.setItem(CHAT_SCOPE_STORAGE_KEY, chatScope);
} catch {
// Ignore storage errors.
}
}, [chatRoomsEnabled, chatScope]);
}, [chatRoomsEnabled, chatScope, persistChatPreferences]);
const activeDraftKey = getChatDraftKey(
chatScope,
@@ -3580,6 +3592,20 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{onOpenSessionInNewWindow && chatScope === "direct" && !showArchivedSessions && contextMenuSession ? (
<button
type="button"
role="menuitem"
data-testid="chat-context-open-window"
onClick={() => {
onOpenSessionInNewWindow(contextMenuSession);
setContextMenu(null);
}}
>
<ExternalLink size={14} />
{t("chat.openInNewWindow", "Open in new window")}
</button>
) : null}
<button
onClick={() => handlePin(
contextMenu.sessionId,

View File

@@ -0,0 +1,53 @@
/*
FNXC:ChatWindows 2026-08-21-18:24:
FN-116 renders every secondary Direct conversation as its own persistent FloatingWindow.
These windows deliberately omit outside dismissal so working in one cannot collapse another.
*/
import { Suspense } from "react";
import type { ChatSessionInfo } from "../hooks/useChat";
import type { PoppedOutChatEntry } from "../hooks/usePoppedOutChats";
import { ChatView } from "./ChatView";
import { FloatingWindow } from "./FloatingWindow";
export interface PoppedOutChatWindowsProps {
entries: PoppedOutChatEntry[];
projectId: string;
addToast: (message: string, type?: "success" | "error" | "warning") => void;
experimentalFeatures?: Record<string, boolean>;
onClose: (projectId: string, sessionId: string) => void;
onOpenSessionInNewWindow: (session: ChatSessionInfo) => void;
}
export function PoppedOutChatWindows({ entries, projectId, addToast, experimentalFeatures, onClose, onOpenSessionInNewWindow }: PoppedOutChatWindowsProps) {
return entries.filter((entry) => entry.projectId === projectId).map((entry) => (
<FloatingWindow
key={`${entry.projectId}:${entry.session.id}`}
windowKey={`chat-window-${entry.projectId}-${entry.session.id}`}
title={entry.session.title || "Chat"}
onClose={() => onClose(entry.projectId, entry.session.id)}
hideHeader
dragHandleSelector=".chat-view--floating .view-header"
className="floating-window--chat"
layer="task-detail"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
persistGeometryKey="kb-dashboard-chat-floating-window"
defaultSize={{ width: 980, height: 680 }}
minSize={{ width: 300, height: 420 }}
ariaLabel={entry.session.title || "Chat"}
>
<Suspense fallback={null}>
<ChatView
projectId={projectId}
addToast={addToast}
experimentalFeatures={experimentalFeatures}
floating
initialDirectSession={entry.session}
persistChatPreferences={false}
onOpenSessionInNewWindow={onOpenSessionInNewWindow}
onClose={() => onClose(entry.projectId, entry.session.id)}
/>
</Suspense>
</FloatingWindow>
));
}

View File

@@ -378,6 +378,21 @@ describe("Chat Session Action Menu", () => {
expect(unarchiveSession).toHaveBeenCalledWith("session-archived");
});
it("opens the exact Direct snapshot without selecting the source host", async () => {
const selectSession = vi.fn();
const onOpenSessionInNewWindow = vi.fn();
const session = { id: "session-window", agentId: "agent-001", status: "active" as const, title: "Window Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" };
setupMockChat({ sessions: [session], filteredSessions: [session], selectSession });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} onOpenSessionInNewWindow={onOpenSessionInNewWindow} />);
fireEvent.contextMenu(screen.getByTestId("chat-session-session-window"));
await userEvent.click(screen.getByTestId("chat-context-open-window"));
expect(onOpenSessionInNewWindow).toHaveBeenCalledWith(session);
expect(selectSession).not.toHaveBeenCalled();
expect(screen.queryByTestId("chat-context-open-window")).not.toBeInTheDocument();
});
it("clicking the action menu button does not select the session", async () => {
const selectSession = vi.fn();
setupMockChat({

View File

@@ -0,0 +1,25 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PoppedOutChatWindows } from "../PoppedOutChatWindows";
vi.mock("../FloatingWindow", () => ({
FloatingWindow: ({ children, onClose, windowKey }: any) => <section data-testid={`window-${windowKey}`}><button onClick={onClose}>close</button>{children}</section>,
}));
vi.mock("../ChatView", () => ({
ChatView: ({ initialDirectSession, onOpenSessionInNewWindow }: any) => <div data-testid={`chat-${initialDirectSession.id}`} onClick={() => onOpenSessionInNewWindow(initialDirectSession)} />,
}));
const entry = (id: string) => ({ projectId: "project-a", session: { id, agentId: "agent-1", title: id, status: "active" as const, createdAt: "2026-08-21T00:00:00.000Z", updatedAt: "2026-08-21T00:00:00.000Z" } });
describe("PoppedOutChatWindows", () => {
it("renders independent selected chats and closes only the requested entry", () => {
const onClose = vi.fn();
const onOpenSessionInNewWindow = vi.fn();
render(<PoppedOutChatWindows entries={[entry("a"), entry("b"), { ...entry("other"), projectId: "project-b" }]} projectId="project-a" addToast={vi.fn()} onClose={onClose} onOpenSessionInNewWindow={onOpenSessionInNewWindow} />);
expect(screen.getByTestId("chat-a")).toBeInTheDocument();
expect(screen.getByTestId("chat-b")).toBeInTheDocument();
expect(screen.queryByTestId("chat-other")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("window-chat-window-project-a-b").querySelector("button")!);
expect(onClose).toHaveBeenCalledWith("project-a", "b");
});
});

View File

@@ -102,6 +102,7 @@ export function MainContent({
experimentalFeatures,
setQuickChatOpen,
chatComposerPrefill,
onOpenSessionInNewWindow,
mailComposerPrefill,
onSendAsReport,
onOpenChatWithPrefill,
@@ -506,6 +507,7 @@ export function MainContent({
initialComposerDraft={chatComposerPrefill?.text}
initialComposerDraftNonce={chatComposerPrefill?.nonce}
onPopOut={() => setQuickChatOpen(true)}
onOpenSessionInNewWindow={onOpenSessionInNewWindow}
onSendAsReport={onSendAsReport}
/>
</Suspense>

View File

@@ -49,6 +49,7 @@ import type { ChatReportHandoff } from "../chatReportHandoff";
import { SettingsView } from "../SettingsModal";
import { AgentsView } from "../AgentsView";
import { ChatView } from "../ChatView";
import type { ChatSessionInfo } from "../../hooks/useChat";
import { CommandCenter } from "../command-center/CommandCenter";
import { DevServerView } from "../DevServerView";
import { DocumentsView } from "../DocumentsView";
@@ -149,6 +150,7 @@ export interface MainContentProps {
skillsEnabled: boolean;
experimentalFeatures: Record<string, boolean>;
setQuickChatOpen: Dispatch<SetStateAction<boolean>>;
onOpenSessionInNewWindow?: (session: ChatSessionInfo) => void;
/** Optional so existing MainContent callers preserve their unseeded Chat behavior. */
chatComposerPrefill?: { text: string; nonce: number } | null;
mailComposerPrefill?: (ChatReportHandoff & { nonce: number }) | null;

View File

@@ -13,6 +13,7 @@ import {
import type { GithubIssueAction, Task, TaskDetail, WorkflowStep } from "@fusion/core";
import type { PluginDashboardViewEntry } from "../api";
import type { ToastType } from "../hooks/useToast";
import type { ChatSessionInfo } from "../hooks/useChat";
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
import { PluginDashboardViewHost } from "../plugins/PluginDashboardViewHost";
import type { DetailTaskTab, PluginDashboardViewContext } from "../plugins/types";
@@ -79,6 +80,7 @@ export interface OverflowViewRenderProps {
onOpenSettings?: (section?: string) => void;
onOpenTaskDetail?: (taskId: string) => void;
onOpenTaskInDock?: (task: Task | TaskDetail) => void;
onOpenSessionInNewWindow?: (session: ChatSessionInfo) => void;
/** Opens New Task with a reverted source task's original description. */
onReviseTask?: (task: Task | TaskDetail) => void;
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; allowResurrection?: boolean }) => Promise<Task>;
@@ -207,6 +209,7 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [
<ChatView
projectId={props.projectId}
addToast={props.addToast}
onOpenSessionInNewWindow={props.onOpenSessionInNewWindow}
compactLayout={props.surface === "dock" && (props.dockWidth ?? RIGHT_DOCK_CHAT_COMPACT_MAX_WIDTH) <= RIGHT_DOCK_CHAT_COMPACT_MAX_WIDTH}
/>,
),

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
import type { ColumnId, GithubIssueAction, MergeResult, Task, TaskDetail, WorkflowStep } from "@fusion/core";
import { isNearDuplicateCanonicalInactive } from "../../../core/src/duplicates/near-duplicate-canonical";
import type { ToastType } from "../hooks/useToast";
import type { ChatSessionInfo } from "../hooks/useChat";
import type { DetailTaskTab } from "../hooks/useModalManager";
import { fetchTaskDetail } from "../api";
import type { RevertTaskOptions, RevertTaskResult } from "../api";
@@ -34,6 +35,7 @@ export interface RightDockControllerInput {
subscribePluginEvents: (pluginId: string, onEvent: (event: { event: string; payload: unknown }) => void) => () => void;
openDetailTask: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
openTaskPopup: (task: Task | TaskDetail) => void;
onOpenSessionInNewWindow?: (session: ChatSessionInfo) => void;
openMobileTasksInPopup: boolean;
openFileInBrowser: (path: string, opts?: { workspace?: string; line?: number; col?: number }) => void;
onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
@@ -243,6 +245,7 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
onReviseTask: (task: Task | TaskDetail) => input.onSendSelectionToTask(task.description),
onDeleteTask: input.onDeleteTask,
onOpenDetail: input.openDetailTask,
onOpenSessionInNewWindow: input.onOpenSessionInNewWindow,
onSendSelectionToTask: input.onSendSelectionToTask,
onCreateTaskFromInsight: input.onCreateTaskFromInsight,
onNavigateToMission: input.onNavigateToMission,

View File

@@ -0,0 +1,24 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { usePoppedOutChats } from "../usePoppedOutChats";
import type { ChatSessionInfo } from "../useChat";
const session = (id: string, title = id): ChatSessionInfo => ({
id, agentId: "agent-1", title, status: "active", createdAt: "2026-08-21T00:00:00.000Z", updatedAt: "2026-08-21T00:00:00.000Z",
});
describe("usePoppedOutChats", () => {
it("deduplicates an exact project/session while retaining distinct windows", () => {
const { result } = renderHook(() => usePoppedOutChats());
act(() => result.current.popOut("project-a", session("a")));
act(() => result.current.popOut("project-a", session("b")));
act(() => result.current.popOut("project-a", session("a", "refreshed")));
expect(result.current.entries).toHaveLength(2);
expect(result.current.entries.find((entry) => entry.session.id === "a")?.session.title).toBe("refreshed");
act(() => result.current.close("project-a", "b"));
expect(result.current.entries.map((entry) => entry.session.id)).toEqual(["a"]);
act(() => result.current.closeAll());
expect(result.current.entries).toEqual([]);
});
});

View File

@@ -112,6 +112,13 @@ import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibi
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
import { useAgentsMapCache } from "./useAgentsMapCache";
export interface UseChatOptions {
/** Forces a window-local Direct selection instead of restoring the shared host selection. */
initialSession?: ChatSessionInfo;
/** Secondary Quick Chats must never rewrite the ordinary host's session preference. */
persistActiveSession?: boolean;
}
export interface UseChatReturn {
// Session state
sessions: ChatSessionInfo[];
@@ -417,7 +424,10 @@ function reconcileOptimisticSentMessage(previous: ChatMessageInfo[], persisted:
export function useChat(
projectId?: string,
addToast?: (msg: string, type?: "success" | "error" | "warning") => void,
options: UseChatOptions = {},
): UseChatReturn {
const persistActiveSession = options.persistActiveSession !== false;
const initialSession = options.initialSession;
// Note: We use i18n lazy - the t function is only used for fallback messages
// and can be undefined since normalizeFailureInfo has a safe default
const getChatSessionsCacheKey = useCallback(
@@ -640,6 +650,22 @@ export function useChat(
useEffect(() => {
if (sessionsLoading || hasRestoredActiveSessionRef.current || activeSessionRef.current) return;
/*
FNXC:ChatWindows 2026-08-21-18:24:
A secondary Quick Chat owns an explicit session and must not let a stale ordinary-host
preference replace it. Its later selections stay local when persistence is disabled.
*/
if (initialSession) {
hasRestoredActiveSessionRef.current = true;
selectSessionRef.current(initialSession.id, initialSession);
return;
}
if (!persistActiveSession) {
hasRestoredActiveSessionRef.current = true;
return;
}
const savedSessionId = getScopedItem(ACTIVE_SESSION_STORAGE_KEY, projectId);
if (!savedSessionId) {
hasRestoredActiveSessionRef.current = true;
@@ -654,7 +680,7 @@ export function useChat(
}
hasRestoredActiveSessionRef.current = true;
}, [sessionsLoading, sessions, projectId]);
}, [initialSession, persistActiveSession, sessionsLoading, sessions, projectId]);
const readCachedMessages = useCallback(
(targetProjectId?: string, sessionId?: string | null) => {
@@ -1124,14 +1150,16 @@ export function useChat(
setMessages([]);
}
// Persist active session to localStorage
if (id) {
setScopedItem(ACTIVE_SESSION_STORAGE_KEY, id, projectId);
} else {
removeScopedItem(ACTIVE_SESSION_STORAGE_KEY, projectId);
// Ordinary Chat hosts retain the project-scoped selection; secondary windows do not.
if (persistActiveSession) {
if (id) {
setScopedItem(ACTIVE_SESSION_STORAGE_KEY, id, projectId);
} else {
removeScopedItem(ACTIVE_SESSION_STORAGE_KEY, projectId);
}
}
},
[attachIfGenerating, hydrateMessagesFromCache, sessions, loadMessages, projectId, resetTransientComposerState],
[attachIfGenerating, hydrateMessagesFromCache, sessions, loadMessages, persistActiveSession, projectId, resetTransientComposerState],
);
// Update the ref to point to the actual selectSession function

View File

@@ -0,0 +1,42 @@
/*
FNXC:ChatWindows 2026-08-21-18:24:
FN-116 keeps secondary Quick Chats in App memory and keys them by project and session.
Reopening a conversation refreshes its snapshot without cloning its independent window.
*/
import { useCallback, useState } from "react";
import type { ChatSessionInfo } from "./useChat";
export interface PoppedOutChatEntry {
projectId: string;
session: ChatSessionInfo;
}
export interface UsePoppedOutChatsResult {
entries: PoppedOutChatEntry[];
popOut: (projectId: string, session: ChatSessionInfo) => void;
close: (projectId: string, sessionId: string) => void;
closeAll: () => void;
}
export function usePoppedOutChats(): UsePoppedOutChatsResult {
const [entries, setEntries] = useState<PoppedOutChatEntry[]>([]);
const popOut = useCallback((projectId: string, session: ChatSessionInfo) => {
setEntries((current) => {
const index = current.findIndex((entry) => entry.projectId === projectId && entry.session.id === session.id);
const next = { projectId, session };
if (index === -1) return [...current, next];
const refreshed = [...current];
refreshed[index] = next;
return refreshed;
});
}, []);
const close = useCallback((projectId: string, sessionId: string) => {
setEntries((current) => current.filter((entry) => entry.projectId !== projectId || entry.session.id !== sessionId));
}, []);
const closeAll = useCallback(() => setEntries([]), []);
return { entries, popOut, close, closeAll };
}

View File

@@ -1263,6 +1263,7 @@
"cancelButton": "Cancel",
"clearConversationFailed": "Failed to clear conversation",
"closeQuickChat": "Close quick chat",
"openInNewWindow": "Open in new window",
"conversationArchived": "Conversation archived",
"conversationDeleted": "Conversation deleted",
"conversationName": "Conversation name",

View File

@@ -1253,6 +1253,7 @@
"cancelButton": "Cancelar",
"clearConversationFailed": "Error al borrar la conversación",
"closeQuickChat": "Cerrar chat rápido",
"openInNewWindow": "",
"conversationArchived": "Conversación archivada",
"conversationDeleted": "Conversación eliminada",
"conversationName": "",

View File

@@ -1253,6 +1253,7 @@
"cancelButton": "Annuler",
"clearConversationFailed": "Échec de l'effacement de la conversation",
"closeQuickChat": "Fermer le chat rapide",
"openInNewWindow": "Ouvrir dans une nouvelle fenêtre",
"conversationArchived": "Conversation archivée",
"conversationDeleted": "Conversation supprimée",
"conversationName": "",

View File

@@ -1253,6 +1253,7 @@
"cancelButton": "취소",
"clearConversationFailed": "대화 초기화 실패",
"closeQuickChat": "빠른 채팅 닫기",
"openInNewWindow": "",
"conversationArchived": "대화가 보관되었습니다",
"conversationDeleted": "대화가 삭제되었습니다",
"conversationName": "",

View File

@@ -1263,6 +1263,7 @@
"cancelButton": "Cancelar",
"clearConversationFailed": "Falha ao limpar a conversa",
"closeQuickChat": "Fechar chat rápido",
"openInNewWindow": "",
"conversationArchived": "Conversa arquivada",
"conversationDeleted": "Conversa excluída",
"conversationName": "Nome da conversa",

View File

@@ -1253,6 +1253,7 @@
"cancelButton": "取消",
"clearConversationFailed": "清除对话失败",
"closeQuickChat": "关闭快速聊天",
"openInNewWindow": "",
"conversationArchived": "对话已归档",
"conversationDeleted": "对话已删除",
"conversationName": "",

View File

@@ -1253,6 +1253,7 @@
"cancelButton": "取消",
"clearConversationFailed": "清除對話失敗",
"closeQuickChat": "關閉快速聊天",
"openInNewWindow": "",
"conversationArchived": "對話已封存",
"conversationDeleted": "對話已刪除",
"conversationName": "",

View File

@@ -1261,6 +1261,13 @@ export default interface Resources {
"conversationDeleted": "Conversation deleted",
"conversationName": "Conversation name",
"conversationRenamed": "Conversation renamed",
"conversationSearchClose": "Close search",
"conversationSearchLabel": "Find in conversation",
"conversationSearchMatchCount": "{{current}} of {{count}} matches",
"conversationSearchNext": "Next match",
"conversationSearchNoMatches": "No matches",
"conversationSearchPlaceholder": "Find in conversation",
"conversationSearchPrevious": "Previous match",
"copyFailed": "Copy failed",
"copyResponse": "Copy response",
"create": "Create",
@@ -1326,6 +1333,7 @@ export default interface Resources {
"noRoomsYet": "No rooms yet.",
"noSkillsAvailable": "No skills available",
"noSkillsFound": "No skills found",
"openInNewWindow": "Open in new window",
"openQuickChat": "Open quick chat",
"pendingEditEmpty": "Queued messages cannot be empty",
"pendingHeading": "Pending messages",
@@ -1377,13 +1385,6 @@ export default interface Resources {
"scopeRooms": "Rooms",
"scrollMessageToTop": "Scroll message to top",
"searchConversations": "Search conversations...",
"conversationSearchPlaceholder": "Find in conversation",
"conversationSearchLabel": "Find in conversation",
"conversationSearchNoMatches": "No matches",
"conversationSearchMatchCount": "{{current}} of {{count}} matches",
"conversationSearchPrevious": "Previous match",
"conversationSearchNext": "Next match",
"conversationSearchClose": "Close search",
"selectAgentForNewChat": "Select agent for new chat",
"selectAgentPlaceholder": "Select an agent to start chatting",
"selectModel": "Select a model",
@@ -6480,6 +6481,29 @@ export default interface Resources {
},
"scopeProject": "Project settings only"
},
"jira": {
"apiBaseUrl": "JIRA API base URL (optional)",
"apiBaseUrlHelp": "No default — unset (derives <site>/rest/api/3 when enabled).",
"baseUrl": "JIRA site URL",
"baseUrlHelp": "JIRA site URL. Default: unset.",
"configuration": "JIRA Configuration",
"email": "JIRA account email (optional)",
"emailHelp": "Email selects Basic authentication. Default: unset.",
"enable": "Enable JIRA integration",
"enabledHelp": "No default — unset (JIRA is opt-in).",
"globalApiBaseUrl": "Global JIRA API base URL (optional)",
"globalBaseUrl": "Global JIRA site URL",
"globalEmail": "Global JIRA account email (optional)",
"globalSecretKey": "Global JIRA token secret key",
"globalSecretScope": "Global JIRA token secret scope",
"globalTemplate": "Global JIRA branch-name template",
"scopeHelp": "No default — unset (effective scope: project).",
"secretHelp": "Secret-store key only. No default — unset (effective key: JIRA_API_TOKEN).",
"secretKey": "JIRA token secret key",
"secretScope": "JIRA token secret scope",
"template": "JIRA branch-name template",
"templateHelp": "No default — unset (effective template: feature/{key}-{summary})."
},
"jsonPlaceholder": "Enter JSON value...",
"keepLocal": "Keep Local",
"keepRemote": "Keep Remote",