Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/electron/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 处理器
Expand Down Expand Up @@ -1574,6 +1576,16 @@ export function registerIpcHandlers(): void {
}
)

// ===== 窗口管理 =====

// 将标签页分离到新窗口
ipcMain.handle(
IPC_CHANNELS.DETACH_TAB,
async (_, input: DetachTabInput): Promise<void> => {
createDetachedWindow(input.type, input.sessionId, input.title, input.screenX, input.screenY)
}
)

console.log('[IPC] IPC 处理器注册完成')

// 注册更新 IPC 处理器
Expand Down
100 changes: 100 additions & 0 deletions apps/electron/src/main/lib/window-manager.ts
Original file line number Diff line number Diff line change
@@ -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' }
})
}
9 changes: 9 additions & 0 deletions apps/electron/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import type {
FeishuNotifyMode,
FeishuNotificationSentPayload,
FeishuUpdateBindingInput,
DetachTabInput,
} from '@proma/shared'
import type { UserProfile, AppSettings } from '../types'

Expand Down Expand Up @@ -547,6 +548,10 @@ export interface ElectronAPI {
onFeishuStatusChanged: (callback: (state: FeishuBridgeState) => void) => () => void
/** 订阅飞书通知已发送事件 */
onFeishuNotificationSent: (callback: (payload: FeishuNotificationSentPayload) => void) => () => void

// ===== 窗口管理 =====
/** 将标签页分离到新窗口 */
detachTab: (input: DetachTabInput) => Promise<void>
}

/**
Expand Down Expand Up @@ -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 对象上
Expand Down
13 changes: 13 additions & 0 deletions apps/electron/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <DetachedWindow />
}

return <MainApp />
}

function MainApp(): React.ReactElement {
const setEnvironmentResult = useSetAtom(environmentCheckResultAtom)
const store = useStore()
const [isLoading, setIsLoading] = React.useState(true)
Expand Down
59 changes: 59 additions & 0 deletions apps/electron/src/renderer/components/DetachedWindow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-screen items-center justify-center bg-background text-muted-foreground text-sm">
无效的窗口参数
</div>
)
}

return (
<TooltipProvider delayDuration={200}>
<div className="flex flex-col h-screen bg-background">
{/* macOS 标题栏拖拽区域 */}
<div className="h-[34px] shrink-0 titlebar-drag-region flex items-center justify-center">
<span className="text-xs text-muted-foreground select-none">{params.title}</span>
</div>

{/* 内容区域 */}
<div className="flex-1 min-h-0">
{params.type === 'chat' ? (
<ChatView conversationId={params.sessionId} />
) : (
<AgentView sessionId={params.sessionId} />
)}
</div>
</div>
</TooltipProvider>
)
}
29 changes: 28 additions & 1 deletion apps/electron/src/renderer/components/tabs/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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) => {
Expand Down
18 changes: 18 additions & 0 deletions packages/shared/src/types/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 通道名称类型
*/
Expand Down