From c47d5551236e986fbbbf025d865a6629153ba666 Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Fri, 21 Aug 2026 18:49:53 +0000 Subject: [PATCH] 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 --- .changeset/fn-116-multiple-quick-chats.md | 7 +++ docs/dashboard-guide.md | 2 + packages/dashboard/app/App.tsx | 34 ++++++++++-- .../dashboard/app/components/ChatView.tsx | 36 +++++++++++-- .../app/components/PoppedOutChatWindows.tsx | 53 +++++++++++++++++++ .../ChatView.core-contracts.test.tsx | 15 ++++++ .../__tests__/PoppedOutChatWindows.test.tsx | 25 +++++++++ .../app/components/dashboard/MainContent.tsx | 2 + .../app/components/dashboard/types.ts | 2 + .../app/components/overflowViewRegistry.tsx | 3 ++ .../app/components/useRightDockController.tsx | 3 ++ .../hooks/__tests__/usePoppedOutChats.test.ts | 24 +++++++++ packages/dashboard/app/hooks/useChat.ts | 42 ++++++++++++--- .../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(-) create mode 100644 .changeset/fn-116-multiple-quick-chats.md create mode 100644 packages/dashboard/app/components/PoppedOutChatWindows.tsx create mode 100644 packages/dashboard/app/components/__tests__/PoppedOutChatWindows.test.tsx create mode 100644 packages/dashboard/app/hooks/__tests__/usePoppedOutChats.test.ts create mode 100644 packages/dashboard/app/hooks/usePoppedOutChats.ts diff --git a/.changeset/fn-116-multiple-quick-chats.md b/.changeset/fn-116-multiple-quick-chats.md new file mode 100644 index 0000000000..df6251f1a2 --- /dev/null +++ b/.changeset/fn-116-multiple-quick-chats.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 576869d1c5..1b2248205c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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**. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 98457bf88a..8260273216 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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>; + poppedOutChatEntries: Array>; 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 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(null); const quickChatProjectIdRef = useRef(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 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() { )} + {currentProject ? ( + + ) : 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. diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 07a91d1640..7ff746e0cd 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -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; } -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 ? ( + + ) : null} {children}, +})); +vi.mock("../ChatView", () => ({ + ChatView: ({ initialDirectSession, onOpenSessionInNewWindow }: any) =>
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(); + 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"); + }); +}); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index fa0408bd7f..0863bc4084 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -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} /> diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index b6747f1b30..4ecf5fc1a7 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -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; setQuickChatOpen: Dispatch>; + 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; diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index 508d3eea49..b7ae52d0cf 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -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; @@ -207,6 +209,7 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ , ), diff --git a/packages/dashboard/app/components/useRightDockController.tsx b/packages/dashboard/app/components/useRightDockController.tsx index 16e3ca1303..c3245888e1 100644 --- a/packages/dashboard/app/components/useRightDockController.tsx +++ b/packages/dashboard/app/components/useRightDockController.tsx @@ -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; @@ -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, diff --git a/packages/dashboard/app/hooks/__tests__/usePoppedOutChats.test.ts b/packages/dashboard/app/hooks/__tests__/usePoppedOutChats.test.ts new file mode 100644 index 0000000000..58fc24d01e --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/usePoppedOutChats.test.ts @@ -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([]); + }); +}); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index d528972a19..6ca77c911f 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -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 diff --git a/packages/dashboard/app/hooks/usePoppedOutChats.ts b/packages/dashboard/app/hooks/usePoppedOutChats.ts new file mode 100644 index 0000000000..81b3d476da --- /dev/null +++ b/packages/dashboard/app/hooks/usePoppedOutChats.ts @@ -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([]); + + 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 }; +} diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 1170ae15ab..2469c19023 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 03810a7d9a..44f50e874e 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -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": "", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index e47af78ace..41bac8804a 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -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": "", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index ac50bf09e3..48be91166d 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -1253,6 +1253,7 @@ "cancelButton": "취소", "clearConversationFailed": "대화 초기화 실패", "closeQuickChat": "빠른 채팅 닫기", + "openInNewWindow": "", "conversationArchived": "대화가 보관되었습니다", "conversationDeleted": "대화가 삭제되었습니다", "conversationName": "", diff --git a/packages/i18n/locales/pt-BR/app.json b/packages/i18n/locales/pt-BR/app.json index 5de80ef145..8ab37a98e1 100644 --- a/packages/i18n/locales/pt-BR/app.json +++ b/packages/i18n/locales/pt-BR/app.json @@ -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", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 6b75785f20..a4e570f4ee 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -1253,6 +1253,7 @@ "cancelButton": "取消", "clearConversationFailed": "清除对话失败", "closeQuickChat": "关闭快速聊天", + "openInNewWindow": "", "conversationArchived": "对话已归档", "conversationDeleted": "对话已删除", "conversationName": "", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 9909922a72..0f56132ee8 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -1253,6 +1253,7 @@ "cancelButton": "取消", "clearConversationFailed": "清除對話失敗", "closeQuickChat": "關閉快速聊天", + "openInNewWindow": "", "conversationArchived": "對話已封存", "conversationDeleted": "對話已刪除", "conversationName": "", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index 42c456e5df..8754729700 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -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 /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",