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:
7
.changeset/fn-116-multiple-quick-chats.md
Normal file
7
.changeset/fn-116-multiple-quick-chats.md
Normal 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.
|
||||
@@ -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**.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
53
packages/dashboard/app/components/PoppedOutChatWindows.tsx
Normal file
53
packages/dashboard/app/components/PoppedOutChatWindows.tsx
Normal 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>
|
||||
));
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
42
packages/dashboard/app/hooks/usePoppedOutChats.ts
Normal file
42
packages/dashboard/app/hooks/usePoppedOutChats.ts
Normal 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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -1253,6 +1253,7 @@
|
||||
"cancelButton": "취소",
|
||||
"clearConversationFailed": "대화 초기화 실패",
|
||||
"closeQuickChat": "빠른 채팅 닫기",
|
||||
"openInNewWindow": "",
|
||||
"conversationArchived": "대화가 보관되었습니다",
|
||||
"conversationDeleted": "대화가 삭제되었습니다",
|
||||
"conversationName": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1253,6 +1253,7 @@
|
||||
"cancelButton": "取消",
|
||||
"clearConversationFailed": "清除对话失败",
|
||||
"closeQuickChat": "关闭快速聊天",
|
||||
"openInNewWindow": "",
|
||||
"conversationArchived": "对话已归档",
|
||||
"conversationDeleted": "对话已删除",
|
||||
"conversationName": "",
|
||||
|
||||
@@ -1253,6 +1253,7 @@
|
||||
"cancelButton": "取消",
|
||||
"clearConversationFailed": "清除對話失敗",
|
||||
"closeQuickChat": "關閉快速聊天",
|
||||
"openInNewWindow": "",
|
||||
"conversationArchived": "對話已封存",
|
||||
"conversationDeleted": "對話已刪除",
|
||||
"conversationName": "",
|
||||
|
||||
38
packages/i18n/src/resources.d.ts
vendored
38
packages/i18n/src/resources.d.ts
vendored
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user