Files
PiAgentUI/src/App.tsx
T
Evandro b3489e1ede
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
🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
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
2026-06-01 00:50:06 -03:00

632 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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