Files
PiAgentUI/src/App.tsx
Evandro 98b5db029f wip: snapshot das alterações não commitadas (2026-10-20)
Origem: D:/Documents/Github/PiAgentUI/PiAgentUi
Arquivos omitidos (segredos, bancos de dados, logs, >50 MB): 4
2026-10-20 00:48:28 -03:00

635 lines
23 KiB
TypeScript
Raw Permalink 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'
import { ProviderOnboarding } from './features/onboarding/ProviderOnboarding'
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>
<ProviderOnboarding />
</ChatViewportProvider>
</div>
)
}
export default App