Spaces:
Running
Running
| import { useEffect, useRef } from 'react'; | |
| import { readStoredValue, writeStoredValue } from './api.js'; | |
| import { DEFAULT_STATUS, relayDisabledReason } from './relay-status.js'; | |
| import { PairingScreen } from './PairingScreen.jsx'; | |
| import { TopBar } from './TopBar.jsx'; | |
| import { | |
| DEFAULT_REASONING_EFFORT, | |
| REASONING_DEFAULT_VERSION, | |
| REASONING_DEFAULT_VERSION_KEY, | |
| REASONING_EFFORT_KEY, | |
| THEME_KEY | |
| } from './app-core-utils.js'; | |
| import { useAppWebSocket } from './hooks/useAppWebSocket.js'; | |
| import { useAppState } from './hooks/useAppState.js'; | |
| import { useChatActions } from './hooks/useChatActions.js'; | |
| import { useDocsActions } from './hooks/useDocsActions.js'; | |
| import { useMessageActions } from './hooks/useMessageActions.js'; | |
| import { useProjectController } from './hooks/useProjectController.js'; | |
| import { useRelayOperationLocks } from './hooks/useRelayOperationLocks.js'; | |
| import { useRunRegistry } from './hooks/useRunRegistry.js'; | |
| import { useMessageSpeech } from './hooks/useMessageSpeech.js'; | |
| import { useTurnPolling } from './hooks/useTurnPolling.js'; | |
| import { useTurnRefresh } from './hooks/useTurnRefresh.js'; | |
| import { useViewportMetrics } from './hooks/useViewportMetrics.js'; | |
| import { useVoiceDialogController } from './hooks/useVoiceDialogController.js'; | |
| import { Drawer } from './components/Drawer.jsx'; | |
| import { DocsPanel } from './components/DocsPanel.jsx'; | |
| import { ChatPane, ImagePreviewModal } from './components/MessageViews.jsx'; | |
| import { VoiceDialogPanel } from './components/VoiceDialogPanel.jsx'; | |
| import { Composer } from './components/Composer.jsx'; | |
| export default function App() { | |
| const app = useAppState(); | |
| const menuButtonRef = useRef(null); | |
| const wasDrawerOpenRef = useRef(false); | |
| const { | |
| status, authenticated, drawerOpen, setDrawerOpen, projects, selectedProject, | |
| expandedProjectIds, hiddenProjectIds, sessionsByProject, loadingProjectId, selectedSession, | |
| messages, previewImage, setPreviewImage, docsOpen, setDocsOpen, docsBusy, | |
| docsError, input, setInput, attachments, uploading, permissionMode, | |
| setPermissionMode, selectedModel, setSelectedModel, selectedReasoningEffort, | |
| setSelectedReasoningEffort, runningById, theme, setTheme, syncing, | |
| connectionState, selectedProjectRef, selectedSessionRef | |
| } = app; | |
| useViewportMetrics(); | |
| const { | |
| rememberLock: rememberRelayOperationLock, | |
| disabledReasons: actionDisabledReasons | |
| } = useRelayOperationLocks(); | |
| const runRegistry = useRunRegistry(app); | |
| const { running } = runRegistry; | |
| const turnRefresh = useTurnRefresh(app, runRegistry); | |
| const turnPolling = useTurnPolling(app, runRegistry, turnRefresh); | |
| const chatActions = useChatActions(app, runRegistry, turnPolling, rememberRelayOperationLock); | |
| const { submitCodexMessage, handleSubmit, handleVoiceSubmit, handleAbort } = chatActions; | |
| const projectController = useProjectController(app, runRegistry); | |
| const { | |
| loadStatus, bootstrap, handleSync, handleToggleProject, | |
| handleHideProject, handleSelectSession, handleRenameSession, handleDeleteSession, handleNewConversation | |
| } = projectController; | |
| const messageSpeech = useMessageSpeech(selectedSession?.id); | |
| const { handleDeleteMessage, handleUploadFiles, handleRemoveAttachment } = useMessageActions( | |
| app, | |
| rememberRelayOperationLock, | |
| { | |
| onDeleteMessageConfirmed: (message) => { | |
| const messageId = String(message?.id || ''); | |
| if ( | |
| messageId && | |
| (messageSpeech.speakingMessageId === messageId || messageSpeech.speechLoadingMessageId === messageId) | |
| ) { | |
| messageSpeech.stopSpeech(); | |
| } | |
| } | |
| } | |
| ); | |
| const { | |
| handleConnectDocs, handleDisconnectDocs, handleRefreshDocs, | |
| handleOpenDocsHome, handleOpenDocsAuth | |
| } = useDocsActions(app, loadStatus); | |
| const voiceDialog = useVoiceDialogController({ | |
| status, | |
| selectedProject, | |
| selectedProjectRef, | |
| messages, | |
| runningById, | |
| rememberRelayOperationLock, | |
| onVoiceSubmit: handleVoiceSubmit, | |
| submitCodexMessage, | |
| onStopMessageSpeech: messageSpeech.stopSpeech | |
| }); | |
| useAppWebSocket(app, runRegistry, turnRefresh); | |
| useEffect(() => { | |
| selectedProjectRef.current = selectedProject; | |
| }, [selectedProject]); | |
| useEffect(() => { | |
| bootstrap(); | |
| }, []); | |
| useEffect(() => { | |
| selectedSessionRef.current = selectedSession; | |
| }, [selectedSession]); | |
| useEffect(() => { | |
| app.hiddenProjectIdsRef.current = hiddenProjectIds; | |
| }, [app.hiddenProjectIdsRef, hiddenProjectIds]); | |
| useEffect(() => { | |
| writeStoredValue(THEME_KEY, theme); | |
| document.documentElement.dataset.theme = theme; | |
| document.documentElement.style.colorScheme = theme; | |
| document | |
| .querySelector('meta[name="theme-color"]') | |
| ?.setAttribute('content', theme === 'dark' ? '#171717' : '#f7f7f4'); | |
| }, [theme]); | |
| useEffect(() => { | |
| if (readStoredValue(REASONING_DEFAULT_VERSION_KEY) !== REASONING_DEFAULT_VERSION) { | |
| writeStoredValue(REASONING_DEFAULT_VERSION_KEY, REASONING_DEFAULT_VERSION); | |
| writeStoredValue(REASONING_EFFORT_KEY, DEFAULT_REASONING_EFFORT); | |
| setSelectedReasoningEffort(DEFAULT_REASONING_EFFORT); | |
| } | |
| }, [setSelectedReasoningEffort]); | |
| useEffect(() => { | |
| if (selectedReasoningEffort) { | |
| writeStoredValue(REASONING_EFFORT_KEY, selectedReasoningEffort); | |
| } | |
| }, [selectedReasoningEffort]); | |
| useEffect(() => { | |
| if (status.model && selectedModel === DEFAULT_STATUS.model) { | |
| setSelectedModel(status.model); | |
| } | |
| }, [selectedModel, status.model]); | |
| useEffect(() => { | |
| const saved = readStoredValue(REASONING_EFFORT_KEY); | |
| if (!saved && status.reasoningEffort && !selectedReasoningEffort) { | |
| setSelectedReasoningEffort(status.reasoningEffort); | |
| } | |
| }, [selectedReasoningEffort, status.reasoningEffort]); | |
| const projectDisabledReason = selectedProject | |
| ? '' | |
| : syncing || !projects.length ? '正在同步项目...' : '未找到可用项目'; | |
| const composerDisabledReason = relayDisabledReason(connectionState) || projectDisabledReason; | |
| const modalBackgroundInert = docsOpen || voiceDialog.open || Boolean(previewImage); | |
| const appContentInert = drawerOpen || modalBackgroundInert; | |
| useEffect(() => { | |
| let focusFrame = null; | |
| if (wasDrawerOpenRef.current && !drawerOpen && !modalBackgroundInert) { | |
| focusFrame = window.requestAnimationFrame(() => { | |
| menuButtonRef.current?.focus(); | |
| }); | |
| } | |
| wasDrawerOpenRef.current = drawerOpen; | |
| return () => { | |
| if (focusFrame !== null) { | |
| window.cancelAnimationFrame(focusFrame); | |
| } | |
| }; | |
| }, [drawerOpen, modalBackgroundInert]); | |
| if (!authenticated) { | |
| return <PairingScreen onPaired={bootstrap} />; | |
| } | |
| const closeDrawer = () => { | |
| setDrawerOpen(false); | |
| }; | |
| return ( | |
| <div className="app-shell"> | |
| <TopBar | |
| selectedProject={selectedProject} | |
| selectedSession={selectedSession} | |
| connectionState={connectionState} | |
| onMenu={() => setDrawerOpen(true)} | |
| onOpenDocs={() => setDocsOpen(true)} | |
| backgroundInert={appContentInert} | |
| menuButtonRef={menuButtonRef} | |
| /> | |
| <Drawer | |
| open={drawerOpen} | |
| onClose={closeDrawer} | |
| projects={projects} | |
| selectedProject={selectedProject} | |
| selectedSession={selectedSession} | |
| expandedProjectIds={expandedProjectIds} | |
| sessionsByProject={sessionsByProject} | |
| loadingProjectId={loadingProjectId} | |
| onToggleProject={handleToggleProject} | |
| onHideProject={handleHideProject} | |
| onSelectSession={handleSelectSession} | |
| onRenameSession={handleRenameSession} | |
| onDeleteSession={handleDeleteSession} | |
| onNewConversation={handleNewConversation} | |
| onSync={handleSync} | |
| syncing={syncing} | |
| hiddenProjectIds={hiddenProjectIds} | |
| theme={theme} | |
| setTheme={setTheme} | |
| backgroundInert={modalBackgroundInert} | |
| /> | |
| <DocsPanel | |
| open={docsOpen} | |
| docs={status.docs} | |
| busy={docsBusy} | |
| error={docsError} | |
| onClose={() => setDocsOpen(false)} | |
| onConnect={handleConnectDocs} | |
| onDisconnect={handleDisconnectDocs} | |
| onOpenHome={handleOpenDocsHome} | |
| onOpenAuth={handleOpenDocsAuth} | |
| onRefresh={handleRefreshDocs} | |
| /> | |
| <ChatPane | |
| messages={messages} | |
| selectedSession={selectedSession} | |
| running={running} | |
| onPreviewImage={setPreviewImage} | |
| onDeleteMessage={handleDeleteMessage} | |
| onSpeakMessage={messageSpeech.speakMessage} | |
| speakingMessageId={messageSpeech.speakingMessageId} | |
| speechLoadingMessageId={messageSpeech.speechLoadingMessageId} | |
| backgroundInert={appContentInert} | |
| /> | |
| <VoiceDialogPanel | |
| open={voiceDialog.open} | |
| state={voiceDialog.state} | |
| error={voiceDialog.error} | |
| transcript={voiceDialog.transcript} | |
| assistantText={voiceDialog.assistantText} | |
| handoffDraft={voiceDialog.handoffDraft} | |
| onHandoffDraftChange={voiceDialog.setHandoffDraft} | |
| onHandoffSubmit={voiceDialog.submitHandoffToCodex} | |
| onHandoffContinue={voiceDialog.continueHandoffCollection} | |
| onHandoffCancel={voiceDialog.cancelHandoffConfirmation} | |
| onStart={voiceDialog.startRecording} | |
| onStop={voiceDialog.stopRecording} | |
| onClose={voiceDialog.closeDialog} | |
| /> | |
| <Composer | |
| input={input} | |
| setInput={setInput} | |
| onSubmit={handleSubmit} | |
| running={running} | |
| onAbort={handleAbort} | |
| models={status.models} | |
| selectedModel={selectedModel} | |
| onSelectModel={setSelectedModel} | |
| selectedReasoningEffort={selectedReasoningEffort} | |
| onSelectReasoningEffort={setSelectedReasoningEffort} | |
| permissionMode={permissionMode} | |
| onSelectPermission={setPermissionMode} | |
| attachments={attachments} | |
| onUploadFiles={handleUploadFiles} | |
| onRemoveAttachment={handleRemoveAttachment} | |
| onRateLimit={rememberRelayOperationLock} | |
| uploading={uploading} | |
| onVoiceSubmit={handleVoiceSubmit} | |
| onOpenVoiceDialog={voiceDialog.openDialog} | |
| voiceDialogActive={voiceDialog.open} | |
| disabled={Boolean(composerDisabledReason)} | |
| disabledReason={composerDisabledReason} | |
| actionDisabledReasons={actionDisabledReasons} | |
| backgroundInert={appContentInert} | |
| /> | |
| <ImagePreviewModal image={previewImage} onClose={() => setPreviewImage(null)} /> | |
| </div> | |
| ); | |
| } | |