diff --git a/apps/electron/src/main/ipc.ts b/apps/electron/src/main/ipc.ts index 8a5bc4ff..09162b5c 100644 --- a/apps/electron/src/main/ipc.ts +++ b/apps/electron/src/main/ipc.ts @@ -66,6 +66,7 @@ import type { FeishuPresenceReport, FeishuNotifyMode, FeishuUpdateBindingInput, + DetachTabInput, } from '@proma/shared' import type { UserProfile, AppSettings } from '../types' import { getRuntimeStatus, getGitRepoStatus } from './lib/runtime-init' @@ -156,6 +157,7 @@ import { watchAttachedDirectory, unwatchAttachedDirectory } from './lib/workspac import { getFeishuConfig, saveFeishuConfig, getDecryptedAppSecret } from './lib/feishu-config' import { feishuBridge } from './lib/feishu-bridge' import { presenceService } from './lib/feishu-presence' +import { createDetachedWindow } from './lib/window-manager' /** * 注册 IPC 处理器 @@ -1574,6 +1576,16 @@ export function registerIpcHandlers(): void { } ) + // ===== 窗口管理 ===== + + // 将标签页分离到新窗口 + ipcMain.handle( + IPC_CHANNELS.DETACH_TAB, + async (_, input: DetachTabInput): Promise => { + createDetachedWindow(input.type, input.sessionId, input.title, input.screenX, input.screenY) + } + ) + console.log('[IPC] IPC 处理器注册完成') // 注册更新 IPC 处理器 diff --git a/apps/electron/src/main/lib/window-manager.ts b/apps/electron/src/main/lib/window-manager.ts new file mode 100644 index 00000000..37d6355e --- /dev/null +++ b/apps/electron/src/main/lib/window-manager.ts @@ -0,0 +1,100 @@ +/** + * 窗口管理模块 + * + * 负责创建分离的标签页窗口 + */ + +import { app, BrowserWindow, shell } from 'electron' +import { join } from 'path' +import { existsSync } from 'fs' + +/** + * Get the appropriate app icon path for the current platform + */ +function getIconPath(): string { + // esbuild 将所有代码打包到 dist/main.cjs,__dirname 始终为 dist/ + const resourcesDir = join(__dirname, 'resources') + + if (process.platform === 'darwin') { + return join(resourcesDir, 'icon.icns') + } else if (process.platform === 'win32') { + return join(resourcesDir, 'icon.ico') + } else { + return join(resourcesDir, 'icon.png') + } +} + +/** + * 创建分离的标签页窗口 + * @param tabType - 标签页类型 (chat | agent) + * @param sessionId - 会话 ID + * @param title - 窗口标题 + * @param screenX - 鼠标释放时的屏幕 X 坐标 + * @param screenY - 鼠标释放时的屏幕 Y 坐标 + */ +export function createDetachedWindow( + tabType: string, + sessionId: string, + title: string, + screenX: number, + screenY: number +): void { + const iconPath = getIconPath() + const iconExists = existsSync(iconPath) + + const width = 1000 + const height = 750 + + const win = new BrowserWindow({ + width, + height, + minWidth: 600, + minHeight: 400, + x: Math.round(screenX - width / 2), + y: Math.round(screenY - 40), + icon: iconExists ? iconPath : undefined, + show: false, + title, + webPreferences: { + preload: join(__dirname, 'preload.cjs'), + contextIsolation: true, + nodeIntegration: false, + }, + titleBarStyle: 'hiddenInset', + trafficLightPosition: { x: 18, y: 18 }, + vibrancy: 'under-window', + visualEffectState: 'active', + }) + + // 构造带查询参数的 URL,渲染进程据此进入单标签模式 + const query = `?detached=1&type=${encodeURIComponent(tabType)}&sessionId=${encodeURIComponent(sessionId)}&title=${encodeURIComponent(title)}` + + const isDev = !app.isPackaged + if (isDev) { + win.loadURL(`http://localhost:5174${query}`) + } else { + win.loadFile(join(__dirname, 'renderer', 'index.html'), { + search: query, + }) + } + + win.once('ready-to-show', () => { + win.show() + }) + + // 拦截外部链接 + win.webContents.on('will-navigate', (event, url) => { + if (isDev && url.startsWith('http://localhost:')) return + event.preventDefault() + if (url.startsWith('http://') || url.startsWith('https://')) { + shell.openExternal(url) + } + }) + + win.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('http://') || url.startsWith('https://')) { + shell.openExternal(url) + } + return { action: 'deny' } + }) +} diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index 10ba9c02..38e21dad 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -77,6 +77,7 @@ import type { FeishuNotifyMode, FeishuNotificationSentPayload, FeishuUpdateBindingInput, + DetachTabInput, } from '@proma/shared' import type { UserProfile, AppSettings } from '../types' @@ -547,6 +548,10 @@ export interface ElectronAPI { onFeishuStatusChanged: (callback: (state: FeishuBridgeState) => void) => () => void /** 订阅飞书通知已发送事件 */ onFeishuNotificationSent: (callback: (payload: FeishuNotificationSentPayload) => void) => () => void + + // ===== 窗口管理 ===== + /** 将标签页分离到新窗口 */ + detachTab: (input: DetachTabInput) => Promise } /** @@ -1170,6 +1175,10 @@ const electronAPI: ElectronAPI = { ipcRenderer.on(FEISHU_IPC_CHANNELS.NOTIFICATION_SENT, listener) return () => { ipcRenderer.removeListener(FEISHU_IPC_CHANNELS.NOTIFICATION_SENT, listener) } }, + + // ===== 窗口管理 ===== + + detachTab: (input: DetachTabInput) => ipcRenderer.invoke(IPC_CHANNELS.DETACH_TAB, input), } // 将 API 暴露到渲染进程的 window 对象上 diff --git a/apps/electron/src/renderer/App.tsx b/apps/electron/src/renderer/App.tsx index 82430c44..85bb23ae 100644 --- a/apps/electron/src/renderer/App.tsx +++ b/apps/electron/src/renderer/App.tsx @@ -5,12 +5,25 @@ import { AppShell } from './components/app-shell/AppShell' import { OnboardingView } from './components/onboarding/OnboardingView' import { TutorialBanner } from './components/tutorial/TutorialBanner' import { TooltipProvider } from './components/ui/tooltip' +import { DetachedWindow } from './components/DetachedWindow' import { environmentCheckResultAtom } from './atoms/environment' import { conversationsAtom } from './atoms/chat-atoms' import { tabsAtom, splitLayoutAtom, openTab } from './atoms/tab-atoms' import type { AppShellContextType } from './contexts/AppShellContext' +/** 检测是否为分离标签页窗口 */ +const isDetachedWindow = new URLSearchParams(window.location.search).get('detached') === '1' + export default function App(): React.ReactElement { + // 分离标签页窗口:直接渲染单标签视图,跳过 onboarding / AppShell + if (isDetachedWindow) { + return + } + + return +} + +function MainApp(): React.ReactElement { const setEnvironmentResult = useSetAtom(environmentCheckResultAtom) const store = useStore() const [isLoading, setIsLoading] = React.useState(true) diff --git a/apps/electron/src/renderer/components/DetachedWindow.tsx b/apps/electron/src/renderer/components/DetachedWindow.tsx new file mode 100644 index 00000000..ba72c12c --- /dev/null +++ b/apps/electron/src/renderer/components/DetachedWindow.tsx @@ -0,0 +1,59 @@ +/** + * DetachedWindow — 分离标签页的独立窗口视图 + * + * 通过 URL 查询参数获取 type / sessionId, + * 直接渲染对应的 ChatView 或 AgentView,不显示 TabBar 和侧边栏。 + */ + +import * as React from 'react' +import { TooltipProvider } from './ui/tooltip' +import { ChatView } from './chat/ChatView' +import { AgentView } from './agent/AgentView' + +interface DetachedParams { + type: string + sessionId: string + title: string +} + +function parseDetachedParams(): DetachedParams | null { + const params = new URLSearchParams(window.location.search) + const type = params.get('type') + const sessionId = params.get('sessionId') + const title = params.get('title') ?? '' + + if (!type || !sessionId) return null + return { type, sessionId, title } +} + +export function DetachedWindow(): React.ReactElement { + const params = React.useMemo(parseDetachedParams, []) + + if (!params) { + return ( +
+ 无效的窗口参数 +
+ ) + } + + return ( + +
+ {/* macOS 标题栏拖拽区域 */} +
+ {params.title} +
+ + {/* 内容区域 */} +
+ {params.type === 'chat' ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/apps/electron/src/renderer/components/tabs/TabBar.tsx b/apps/electron/src/renderer/components/tabs/TabBar.tsx index e4716c7f..085481ca 100644 --- a/apps/electron/src/renderer/components/tabs/TabBar.tsx +++ b/apps/electron/src/renderer/components/tabs/TabBar.tsx @@ -105,10 +105,37 @@ export function TabBar(): React.ReactElement { startIndex: idx, } + // 标签页分离检测阈值(像素) + const DETACH_THRESHOLD_Y = 60 + const handleMove = (me: PointerEvent): void => { if (!dragState.current) return const dx = Math.abs(me.clientX - dragState.current.startX) if (dx > 5) dragState.current.dragging = true + + // 检测垂直方向拖出标签栏 → 分离标签页 + const dy = me.clientY - e.clientY + if (dy > DETACH_THRESHOLD_Y && tabs.length > 1) { + const tab = tabs.find((t) => t.id === dragState.current?.tabId) + if (!tab) return + + // 清理拖拽状态 + document.removeEventListener('pointermove', handleMove) + document.removeEventListener('pointerup', handleUp) + dragState.current = null + + // 调用 IPC 分离标签页 + window.electronAPI.detachTab({ + type: tab.type, + sessionId: tab.id, + title: tab.title, + screenX: me.screenX, + screenY: me.screenY, + }) + + // 关闭当前窗口中的标签 + handleClose(tab.id) + } } const handleUp = (): void => { @@ -119,7 +146,7 @@ export function TabBar(): React.ReactElement { document.addEventListener('pointermove', handleMove) document.addEventListener('pointerup', handleUp) - }, [tabs]) + }, [tabs, handleClose]) // 水平滚动支持 const handleWheel = React.useCallback((e: React.WheelEvent) => { diff --git a/packages/shared/src/types/runtime.ts b/packages/shared/src/types/runtime.ts index 86e62375..bc11fd48 100644 --- a/packages/shared/src/types/runtime.ts +++ b/packages/shared/src/types/runtime.ts @@ -194,8 +194,26 @@ export const IPC_CHANNELS = { GET_GIT_REPO_STATUS: 'git:get-repo-status', /** 在系统默认浏览器中打开外部链接 */ OPEN_EXTERNAL: 'shell:open-external', + /** 将标签页分离到新窗口 */ + DETACH_TAB: 'window:detach-tab', } as const +/** + * 标签页分离请求参数 + */ +export interface DetachTabInput { + /** 标签页类型 */ + type: 'chat' | 'agent' + /** 会话 ID */ + sessionId: string + /** 标签页标题 */ + title: string + /** 鼠标释放时的屏幕 X 坐标 */ + screenX: number + /** 鼠标释放时的屏幕 Y 坐标 */ + screenY: number +} + /** * IPC 通道名称类型 */