🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
A professional workspace for AI coding agents with: - Real-time streaming chat with SSE - Session management (fork, clone, tree navigation) - MCP tools browser & metadata panel - Skills browser (global, project, package sources) - Voice input & STT provider configuration - Integrated terminal & file explorer - Model selector with capability metadata - Keyboard-first UI with command palette - 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi) - Tauri 2 desktop app - React 19 + TypeScript + Vite 8 + Tailwind CSS 4 - Pi Agent extension runtime Originally forked from OpenCodeUI, now an independent project. License: GPL-3.0-only
This commit is contained in:
commit
b3489e1ede
983 files changed
+158475
No files matched your search
+631
@@ -0,0 +1,631 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { Sidebar } from './features/chat'
|
||||
import { ChatPane } from './features/chat/ChatPane'
|
||||
import { SplitContainer } from './features/chat/SplitContainer'
|
||||
import type { CommandItem } from './components/CommandPalette'
|
||||
import { ToastContainer } from './components/ToastContainer'
|
||||
import { RightPanel } from './components/RightPanel'
|
||||
import { BottomPanel } from './components/BottomPanel'
|
||||
import { DesktopTitlebar } from './components/DesktopTitlebar'
|
||||
import { useDirectory, useGlobalEvents, useGlobalKeybindings, useRouter } from './hooks'
|
||||
import { useViewportHeight } from './hooks/useViewportHeight'
|
||||
import { useCloseServiceDialog } from './hooks/useCloseServiceDialog'
|
||||
import { useWakeLock } from './hooks/useWakeLock'
|
||||
import type { KeybindingHandlers } from './hooks/useKeybindings'
|
||||
import { keybindingStore } from './store/keybindingStore'
|
||||
import {
|
||||
layoutStore,
|
||||
paneLayoutStore,
|
||||
useLayoutStore,
|
||||
usePaneController,
|
||||
usePaneControllers,
|
||||
usePaneLayout,
|
||||
updateStore,
|
||||
} from './store'
|
||||
import {
|
||||
ChatViewportProvider,
|
||||
CHAT_SURFACE_MIN_WIDTH,
|
||||
canUseSplitPane,
|
||||
useChatViewportController,
|
||||
} from './features/chat/chatViewport'
|
||||
import { uiErrorHandler, isSameDirectory, collectActiveDirectories } from './utils'
|
||||
import { initNotificationSound } from './utils/notificationSoundBridge'
|
||||
import { createPtySession } from './api/pty'
|
||||
import type { TerminalTab } from './store/layoutStore'
|
||||
import type { SettingsTab } from './features/settings/SettingsDialog'
|
||||
import { isTauri, isTauriMobile } from './utils/tauri'
|
||||
|
||||
const SettingsDialog = lazy(() =>
|
||||
import('./features/settings/SettingsDialog').then(module => ({ default: module.SettingsDialog })),
|
||||
)
|
||||
const CommandPalette = lazy(() =>
|
||||
import('./components/CommandPalette').then(module => ({ default: module.CommandPalette })),
|
||||
)
|
||||
const CloseServiceDialog = lazy(() =>
|
||||
import('./components/CloseServiceDialog').then(module => ({ default: module.CloseServiceDialog })),
|
||||
)
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation(['commands', 'chat', 'common', 'components'])
|
||||
const router = useRouter()
|
||||
const {
|
||||
sessionId: routeSessionId,
|
||||
directory: routeDirectory,
|
||||
navigateToSession: navigateRouteToSession,
|
||||
navigateHome: navigateRouteHome,
|
||||
replaceSession,
|
||||
} = router
|
||||
const { currentDirectory, savedDirectories, sidebarExpanded, setSidebarExpanded } = useDirectory()
|
||||
const { rightPanelOpen, rightPanelWidth, wakeLock } = useLayoutStore()
|
||||
const { surfaceRef, value: chatViewport } = useChatViewportController({
|
||||
sidebarExpanded,
|
||||
rightPanelOpen,
|
||||
requestedRightPanelWidth: rightPanelWidth,
|
||||
})
|
||||
const splitPaneEnabled = canUseSplitPane(chatViewport)
|
||||
const paneLayout = usePaneLayout()
|
||||
const focusedController = usePaneController(paneLayout.focusedPaneId)
|
||||
const paneControllers = usePaneControllers()
|
||||
const syncingFromRouteRef = useRef(false)
|
||||
const lastRouteSessionIdRef = useRef<string | null | undefined>(undefined)
|
||||
// 当 currentDirectory 为 undefined 时表示全局模式,
|
||||
// 不应 fallback 到 session 自身的 directory,否则 replaceSession 会把 dir 参数写回 URL
|
||||
const focusedRouteDirectory =
|
||||
currentDirectory !== undefined
|
||||
? paneLayout.focusedSessionId === routeSessionId
|
||||
? routeDirectory || focusedController?.effectiveDirectory || currentDirectory
|
||||
: focusedController?.effectiveDirectory || currentDirectory
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = initNotificationSound()
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri() || isTauriMobile()) return
|
||||
|
||||
void invoke('desktop_window_ready').catch(() => {
|
||||
// best effort only
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV) return
|
||||
void updateStore.checkForUpdates()
|
||||
}, [])
|
||||
|
||||
useViewportHeight()
|
||||
useWakeLock(wakeLock)
|
||||
|
||||
const activeDirectories = useMemo(
|
||||
() =>
|
||||
collectActiveDirectories({
|
||||
routeDirectory,
|
||||
currentDirectory,
|
||||
paneDirectories: paneControllers
|
||||
.map(controller => controller.effectiveDirectory)
|
||||
.filter((directory): directory is string => Boolean(directory)),
|
||||
projectDirectories: savedDirectories.map(directory => directory.path),
|
||||
}),
|
||||
[routeDirectory, currentDirectory, paneControllers, savedDirectories],
|
||||
)
|
||||
|
||||
// 全局唯一 SSE 连接。所有 pane 通过 consumer 机制接收自己的 session 事件。
|
||||
useGlobalEvents(activeDirectories)
|
||||
|
||||
// URL -> focused pane session
|
||||
useEffect(() => {
|
||||
if (lastRouteSessionIdRef.current === routeSessionId) return
|
||||
lastRouteSessionIdRef.current = routeSessionId
|
||||
if (paneLayoutStore.getFocusedSessionId() === routeSessionId) return
|
||||
syncingFromRouteRef.current = true
|
||||
paneLayoutStore.setFocusedSession(routeSessionId)
|
||||
}, [routeSessionId])
|
||||
|
||||
// focused pane session -> URL(路由只反映当前 focused pane)
|
||||
useEffect(() => {
|
||||
if (syncingFromRouteRef.current) {
|
||||
syncingFromRouteRef.current = false
|
||||
return
|
||||
}
|
||||
if (paneLayoutStore.getFocusedSessionId() !== paneLayout.focusedSessionId) return
|
||||
if (paneLayout.focusedSessionId === routeSessionId && isSameDirectory(routeDirectory, focusedRouteDirectory)) return
|
||||
replaceSession(paneLayout.focusedSessionId, focusedRouteDirectory)
|
||||
}, [
|
||||
paneLayout.focusedPaneId,
|
||||
paneLayout.focusedSessionId,
|
||||
routeSessionId,
|
||||
routeDirectory,
|
||||
replaceSession,
|
||||
focusedRouteDirectory,
|
||||
])
|
||||
|
||||
const navigatePaneToSession = useCallback(
|
||||
(paneId: string, sessionId: string, directory?: string) => {
|
||||
paneLayoutStore.focusPane(paneId)
|
||||
paneLayoutStore.setPaneSession(paneId, sessionId)
|
||||
navigateRouteToSession(sessionId, directory)
|
||||
},
|
||||
[navigateRouteToSession],
|
||||
)
|
||||
|
||||
const navigatePaneHome = useCallback(
|
||||
(paneId: string) => {
|
||||
paneLayoutStore.focusPane(paneId)
|
||||
paneLayoutStore.setPaneSession(paneId, null)
|
||||
navigateRouteHome()
|
||||
},
|
||||
[navigateRouteHome],
|
||||
)
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(session: { id: string; directory?: string }) => {
|
||||
const paneId = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
|
||||
if (!paneId) return
|
||||
navigatePaneToSession(paneId, session.id, session.directory)
|
||||
},
|
||||
[paneLayout.focusedPaneId, navigatePaneToSession],
|
||||
)
|
||||
|
||||
const handleNewSession = useCallback(() => {
|
||||
const paneId = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
|
||||
if (!paneId) return
|
||||
navigatePaneHome(paneId)
|
||||
}, [paneLayout.focusedPaneId, navigatePaneHome])
|
||||
|
||||
const handleEnterSplitMode = useCallback(() => {
|
||||
paneLayoutStore.enterSplitMode(paneLayout.focusedSessionId)
|
||||
}, [paneLayout.focusedSessionId])
|
||||
|
||||
const handleToggleFocusedPaneFullscreen = useCallback(() => {
|
||||
const paneId = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
|
||||
if (!paneId) return
|
||||
paneLayoutStore.togglePaneFullscreen(paneId)
|
||||
}, [paneLayout.focusedPaneId])
|
||||
|
||||
const handleOpenSidebar = useCallback(() => setSidebarExpanded(true), [setSidebarExpanded])
|
||||
|
||||
const renderPaneLeaf = useCallback(
|
||||
(paneId: string, paneSessionId: string | null) => (
|
||||
<ChatPane
|
||||
key={paneId}
|
||||
paneId={paneId}
|
||||
sessionId={paneSessionId}
|
||||
isFocused={paneLayout.focusedPaneId === paneId}
|
||||
paneCount={paneLayout.paneCount}
|
||||
displayMode={paneLayout.isSplit && paneLayout.fullscreenPaneId !== paneId ? 'split' : 'single'}
|
||||
isPaneFullscreen={paneLayout.fullscreenPaneId === paneId}
|
||||
onOpenSidebar={handleOpenSidebar}
|
||||
showSidebarButton={chatViewport.interaction.sidebarBehavior === 'overlay'}
|
||||
onSplitPane={splitPaneEnabled && !paneLayout.fullscreenPaneId ? handleEnterSplitMode : undefined}
|
||||
onTogglePaneFullscreen={paneLayout.isSplit ? handleToggleFocusedPaneFullscreen : undefined}
|
||||
navigatePaneToSession={navigatePaneToSession}
|
||||
navigatePaneHome={navigatePaneHome}
|
||||
/>
|
||||
),
|
||||
[
|
||||
paneLayout.focusedPaneId,
|
||||
paneLayout.paneCount,
|
||||
paneLayout.isSplit,
|
||||
paneLayout.fullscreenPaneId,
|
||||
chatViewport.interaction.sidebarBehavior,
|
||||
splitPaneEnabled,
|
||||
handleOpenSidebar,
|
||||
handleEnterSplitMode,
|
||||
handleToggleFocusedPaneFullscreen,
|
||||
navigatePaneToSession,
|
||||
navigatePaneHome,
|
||||
],
|
||||
)
|
||||
|
||||
const focusedDirectory = focusedRouteDirectory || ''
|
||||
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false)
|
||||
const [settingsInitialTab, setSettingsInitialTab] = useState<SettingsTab>('servers')
|
||||
const openSettingsTab = useCallback((tab: SettingsTab) => {
|
||||
setSettingsInitialTab(tab)
|
||||
setSettingsDialogOpen(true)
|
||||
}, [])
|
||||
const openSettings = useCallback(() => {
|
||||
openSettingsTab('servers')
|
||||
}, [openSettingsTab])
|
||||
const openAboutSettings = useCallback(() => {
|
||||
openSettingsTab('about')
|
||||
}, [openSettingsTab])
|
||||
const closeSettings = useCallback(() => setSettingsDialogOpen(false), [])
|
||||
|
||||
const [projectDialogOpen, setProjectDialogOpen] = useState(false)
|
||||
const openProject = useCallback(() => setProjectDialogOpen(true), [])
|
||||
const closeProjectDialog = useCallback(() => setProjectDialogOpen(false), [])
|
||||
|
||||
// 桌面标题栏通过 CustomEvent 触发打开项目/设置
|
||||
useEffect(() => {
|
||||
const onOpenProject = () => openProject()
|
||||
const onOpenSettings = () => openSettings()
|
||||
window.addEventListener('titlebar:open-project', onOpenProject)
|
||||
window.addEventListener('titlebar:open-settings', onOpenSettings)
|
||||
return () => {
|
||||
window.removeEventListener('titlebar:open-project', onOpenProject)
|
||||
window.removeEventListener('titlebar:open-settings', onOpenSettings)
|
||||
}
|
||||
}, [openProject, openSettings])
|
||||
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
|
||||
|
||||
const handleNewTerminal = useCallback(async () => {
|
||||
try {
|
||||
const pty = await createPtySession({ cwd: focusedDirectory }, focusedDirectory)
|
||||
const tab: TerminalTab = {
|
||||
id: pty.id,
|
||||
title: pty.title || t('components:terminal.terminal'),
|
||||
status: 'connecting',
|
||||
}
|
||||
layoutStore.addTerminalTab(tab, true)
|
||||
} catch (error) {
|
||||
uiErrorHandler('create terminal', error)
|
||||
}
|
||||
}, [focusedDirectory, t])
|
||||
|
||||
const keybindingHandlers = useMemo<KeybindingHandlers>(
|
||||
() => ({
|
||||
openSettings,
|
||||
openProject,
|
||||
commandPalette: () => setCommandPaletteOpen(true),
|
||||
toggleSidebar: () => setSidebarExpanded(!sidebarExpanded),
|
||||
toggleRightPanel: () => layoutStore.toggleRightPanel(),
|
||||
focusInput: () => {
|
||||
const input = document.querySelector<HTMLTextAreaElement>('[data-input-box] textarea')
|
||||
input?.focus()
|
||||
},
|
||||
newSession: () => focusedController?.newSession(),
|
||||
archiveSession: () => focusedController?.archiveSession(),
|
||||
previousSession: () => focusedController?.previousSession(),
|
||||
nextSession: () => focusedController?.nextSession(),
|
||||
toggleTerminal: () => layoutStore.toggleBottomPanel(),
|
||||
newTerminal: handleNewTerminal,
|
||||
selectModel: () => focusedController?.openModelSelector(),
|
||||
toggleAgent: () => focusedController?.toggleAgent(),
|
||||
cancelMessage: () => focusedController?.cancelMessage(),
|
||||
copyLastResponse: () => focusedController?.copyLastResponse(),
|
||||
toggleFullAuto: () => focusedController?.toggleFullAuto(),
|
||||
// Pane
|
||||
focusNextPane: () => {
|
||||
paneLayoutStore.focusNextPane()
|
||||
requestAnimationFrame(() => {
|
||||
const pid = paneLayoutStore.getFocusedPaneId()
|
||||
if (pid) {
|
||||
const input = document.querySelector<HTMLTextAreaElement>(`[data-pane-id="${pid}"] textarea`)
|
||||
input?.focus()
|
||||
}
|
||||
})
|
||||
},
|
||||
focusPrevPane: () => {
|
||||
paneLayoutStore.focusPrevPane()
|
||||
requestAnimationFrame(() => {
|
||||
const pid = paneLayoutStore.getFocusedPaneId()
|
||||
if (pid) {
|
||||
const input = document.querySelector<HTMLTextAreaElement>(`[data-pane-id="${pid}"] textarea`)
|
||||
input?.focus()
|
||||
}
|
||||
})
|
||||
},
|
||||
splitRight: () => {
|
||||
const pid = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
|
||||
if (pid && splitPaneEnabled) paneLayoutStore.splitPane(pid, 'horizontal')
|
||||
},
|
||||
splitDown: () => {
|
||||
const pid = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
|
||||
if (pid && splitPaneEnabled) paneLayoutStore.splitPane(pid, 'vertical')
|
||||
},
|
||||
closePane: () => {
|
||||
const pid = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
|
||||
if (pid && paneLayout.isSplit) paneLayoutStore.closePane(pid)
|
||||
},
|
||||
togglePaneFullscreen: () => {
|
||||
if (paneLayout.isSplit) handleToggleFocusedPaneFullscreen()
|
||||
},
|
||||
}),
|
||||
[
|
||||
openSettings,
|
||||
openProject,
|
||||
sidebarExpanded,
|
||||
setSidebarExpanded,
|
||||
focusedController,
|
||||
handleNewTerminal,
|
||||
paneLayout.focusedPaneId,
|
||||
paneLayout.isSplit,
|
||||
splitPaneEnabled,
|
||||
handleToggleFocusedPaneFullscreen,
|
||||
],
|
||||
)
|
||||
|
||||
useGlobalKeybindings(keybindingHandlers)
|
||||
|
||||
const commands = useMemo<CommandItem[]>(() => {
|
||||
const getShortcut = (action: string) =>
|
||||
keybindingStore.getKey(action as import('./store/keybindingStore').KeybindingAction)
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'openSettings',
|
||||
label: t('commands:openSettings'),
|
||||
description: t('commands:openSettingsDesc'),
|
||||
category: t('commands:categories.general'),
|
||||
shortcut: getShortcut('openSettings'),
|
||||
action: openSettings,
|
||||
},
|
||||
{
|
||||
id: 'openProject',
|
||||
label: t('commands:openProject'),
|
||||
description: t('commands:openProjectDesc'),
|
||||
category: t('commands:categories.general'),
|
||||
shortcut: getShortcut('openProject'),
|
||||
action: openProject,
|
||||
},
|
||||
{
|
||||
id: 'openSettingsShortcuts',
|
||||
label: t('commands:openShortcutsSettings'),
|
||||
description: t('commands:openShortcutsSettingsDesc'),
|
||||
category: t('commands:categories.general'),
|
||||
action: () => {
|
||||
openSettingsTab('keybindings')
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'toggleSidebar',
|
||||
label: t('commands:toggleSidebar'),
|
||||
description: t('commands:toggleSidebarDesc'),
|
||||
category: t('commands:categories.general'),
|
||||
shortcut: getShortcut('toggleSidebar'),
|
||||
action: () => setSidebarExpanded(!sidebarExpanded),
|
||||
},
|
||||
{
|
||||
id: 'toggleRightPanel',
|
||||
label: t('commands:toggleRightPanel'),
|
||||
description: t('commands:toggleRightPanelDesc'),
|
||||
category: t('commands:categories.general'),
|
||||
shortcut: getShortcut('toggleRightPanel'),
|
||||
action: () => layoutStore.toggleRightPanel(),
|
||||
},
|
||||
{
|
||||
id: 'focusInput',
|
||||
label: t('commands:focusInput'),
|
||||
description: t('commands:focusInputDesc'),
|
||||
category: t('commands:categories.general'),
|
||||
shortcut: getShortcut('focusInput'),
|
||||
action: () => {
|
||||
const input = document.querySelector<HTMLTextAreaElement>('[data-input-box] textarea')
|
||||
input?.focus()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'newSession',
|
||||
label: t('commands:newSession'),
|
||||
description: t('commands:newSessionDesc'),
|
||||
category: t('commands:categories.session'),
|
||||
shortcut: getShortcut('newSession'),
|
||||
action: () => focusedController?.newSession(),
|
||||
},
|
||||
{
|
||||
id: 'archiveSession',
|
||||
label: t('commands:archiveSession'),
|
||||
description: t('commands:archiveSessionDesc'),
|
||||
category: t('commands:categories.session'),
|
||||
shortcut: getShortcut('archiveSession'),
|
||||
action: () => focusedController?.archiveSession(),
|
||||
},
|
||||
{
|
||||
id: 'previousSession',
|
||||
label: t('commands:previousSession'),
|
||||
description: t('commands:previousSessionDesc'),
|
||||
category: t('commands:categories.session'),
|
||||
shortcut: getShortcut('previousSession'),
|
||||
action: () => focusedController?.previousSession(),
|
||||
},
|
||||
{
|
||||
id: 'nextSession',
|
||||
label: t('commands:nextSession'),
|
||||
description: t('commands:nextSessionDesc'),
|
||||
category: t('commands:categories.session'),
|
||||
shortcut: getShortcut('nextSession'),
|
||||
action: () => focusedController?.nextSession(),
|
||||
},
|
||||
{
|
||||
id: 'toggleTerminal',
|
||||
label: t('commands:toggleTerminal'),
|
||||
description: t('commands:toggleTerminalDesc'),
|
||||
category: t('commands:categories.terminal'),
|
||||
shortcut: getShortcut('toggleTerminal'),
|
||||
action: () => layoutStore.toggleBottomPanel(),
|
||||
},
|
||||
{
|
||||
id: 'newTerminal',
|
||||
label: t('commands:newTerminal'),
|
||||
description: t('commands:newTerminalDesc'),
|
||||
category: t('commands:categories.terminal'),
|
||||
shortcut: getShortcut('newTerminal'),
|
||||
action: handleNewTerminal,
|
||||
},
|
||||
{
|
||||
id: 'selectModel',
|
||||
label: t('commands:selectModel'),
|
||||
description: t('commands:selectModelDesc'),
|
||||
category: t('commands:categories.model'),
|
||||
shortcut: getShortcut('selectModel'),
|
||||
action: () => focusedController?.openModelSelector(),
|
||||
},
|
||||
{
|
||||
id: 'toggleAgent',
|
||||
label: t('commands:toggleAgent'),
|
||||
description: t('commands:toggleAgentDesc'),
|
||||
category: t('commands:categories.model'),
|
||||
shortcut: getShortcut('toggleAgent'),
|
||||
action: () => focusedController?.toggleAgent(),
|
||||
},
|
||||
{
|
||||
id: 'copyLastResponse',
|
||||
label: t('commands:copyLastResponse'),
|
||||
description: t('commands:copyLastResponseDesc'),
|
||||
category: t('commands:categories.message'),
|
||||
shortcut: getShortcut('copyLastResponse'),
|
||||
action: () => focusedController?.copyLastResponse(),
|
||||
},
|
||||
{
|
||||
id: 'cancelMessage',
|
||||
label: t('commands:cancelMessage'),
|
||||
description: t('commands:cancelMessageDesc'),
|
||||
category: t('commands:categories.message'),
|
||||
shortcut: getShortcut('cancelMessage'),
|
||||
action: () => focusedController?.cancelMessage(),
|
||||
when: () => !!focusedController?.isStreaming,
|
||||
},
|
||||
// Pane
|
||||
{
|
||||
id: 'focusNextPane',
|
||||
label: t('commands:focusNextPane'),
|
||||
description: t('commands:focusNextPaneDesc'),
|
||||
category: t('commands:categories.pane'),
|
||||
shortcut: getShortcut('focusNextPane'),
|
||||
action: () => paneLayoutStore.focusNextPane(),
|
||||
},
|
||||
{
|
||||
id: 'focusPrevPane',
|
||||
label: t('commands:focusPrevPane'),
|
||||
description: t('commands:focusPrevPaneDesc'),
|
||||
category: t('commands:categories.pane'),
|
||||
shortcut: getShortcut('focusPrevPane'),
|
||||
action: () => paneLayoutStore.focusPrevPane(),
|
||||
},
|
||||
{
|
||||
id: 'splitRight',
|
||||
label: t('commands:splitRight'),
|
||||
description: t('commands:splitRightDesc'),
|
||||
category: t('commands:categories.pane'),
|
||||
shortcut: getShortcut('splitRight'),
|
||||
action: () => {
|
||||
const pid = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
|
||||
if (pid && splitPaneEnabled) paneLayoutStore.splitPane(pid, 'horizontal')
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'splitDown',
|
||||
label: t('commands:splitDown'),
|
||||
description: t('commands:splitDownDesc'),
|
||||
category: t('commands:categories.pane'),
|
||||
shortcut: getShortcut('splitDown'),
|
||||
action: () => {
|
||||
const pid = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
|
||||
if (pid && splitPaneEnabled) paneLayoutStore.splitPane(pid, 'vertical')
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'closePane',
|
||||
label: t('commands:closePane'),
|
||||
description: t('commands:closePaneDesc'),
|
||||
category: t('commands:categories.pane'),
|
||||
shortcut: getShortcut('closePane'),
|
||||
action: () => {
|
||||
const pid = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
|
||||
if (pid && paneLayout.isSplit) paneLayoutStore.closePane(pid)
|
||||
},
|
||||
when: () => paneLayout.isSplit,
|
||||
},
|
||||
{
|
||||
id: 'togglePaneFullscreen',
|
||||
label: t('commands:togglePaneFullscreen'),
|
||||
description: t('commands:togglePaneFullscreenDesc'),
|
||||
category: t('commands:categories.pane'),
|
||||
shortcut: getShortcut('togglePaneFullscreen'),
|
||||
action: () => {
|
||||
if (paneLayout.isSplit) handleToggleFocusedPaneFullscreen()
|
||||
},
|
||||
when: () => paneLayout.isSplit,
|
||||
},
|
||||
]
|
||||
}, [
|
||||
t,
|
||||
openSettings,
|
||||
openProject,
|
||||
openSettingsTab,
|
||||
sidebarExpanded,
|
||||
setSidebarExpanded,
|
||||
focusedController,
|
||||
handleNewTerminal,
|
||||
paneLayout.focusedPaneId,
|
||||
paneLayout.isSplit,
|
||||
splitPaneEnabled,
|
||||
handleToggleFocusedPaneFullscreen,
|
||||
])
|
||||
|
||||
const { showCloseDialog, handleCloseDialogConfirm, handleCloseDialogCancel } = useCloseServiceDialog()
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col bg-bg-100 overflow-hidden">
|
||||
<DesktopTitlebar />
|
||||
<ChatViewportProvider value={chatViewport}>
|
||||
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<Sidebar
|
||||
isOpen={sidebarExpanded}
|
||||
selectedSessionId={paneLayout.focusedSessionId}
|
||||
onSelectSession={handleSelectSession}
|
||||
onNewSession={handleNewSession}
|
||||
onOpen={handleOpenSidebar}
|
||||
onClose={() => setSidebarExpanded(false)}
|
||||
contextLimit={focusedController?.contextLimit}
|
||||
onOpenSettings={openSettings}
|
||||
projectDialogOpen={projectDialogOpen}
|
||||
onProjectDialogClose={closeProjectDialog}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex min-w-0 h-full overflow-hidden">
|
||||
<div
|
||||
ref={surfaceRef}
|
||||
className="flex-1 flex flex-col min-w-0 overflow-hidden"
|
||||
style={{
|
||||
minWidth:
|
||||
chatViewport.interaction.sidebarBehavior === 'overlay' ? undefined : `${CHAT_SURFACE_MIN_WIDTH}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={paneLayout.isSplit && !paneLayout.fullscreenPaneId ? 'flex-1 min-h-0 p-2' : 'flex-1 min-h-0'}
|
||||
>
|
||||
<SplitContainer
|
||||
node={paneLayout.root}
|
||||
renderLeaf={renderPaneLeaf}
|
||||
fullscreenPaneId={paneLayout.fullscreenPaneId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BottomPanel directory={focusedDirectory} />
|
||||
</div>
|
||||
|
||||
<RightPanel directory={focusedDirectory} sessionId={paneLayout.focusedSessionId} />
|
||||
</div>
|
||||
<ToastContainer onOpenAbout={openAboutSettings} />
|
||||
</div>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<SettingsDialog isOpen={settingsDialogOpen} onClose={closeSettings} initialTab={settingsInitialTab} />
|
||||
<CommandPalette
|
||||
isOpen={commandPaletteOpen}
|
||||
onClose={() => setCommandPaletteOpen(false)}
|
||||
commands={commands}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<CloseServiceDialog
|
||||
isOpen={showCloseDialog}
|
||||
onConfirm={handleCloseDialogConfirm}
|
||||
onCancel={handleCloseDialogCancel}
|
||||
/>
|
||||
</Suspense>
|
||||
</ChatViewportProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Reference in new issue
Block a user