diff --git a/src/App.tsx b/src/App.tsx index 75b0dab..24b8db3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,31 +5,28 @@ import React, { Suspense, lazy, useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'motion/react'; -import { Menu, X as XIcon, Wifi } from 'lucide-react'; +import { Wifi } from 'lucide-react'; import TopBar from './components/TopBar'; import Sidebar from './components/Sidebar'; import MainArea from './components/MainArea'; -import NewsPopup, { shouldShowNewsPopup, dismissNewsPopup } from './components/NewsPopup'; import { AppProvider, useAppContext } from './AppContext'; import { isVideoPath, toFileUrl } from './media'; import UpdateModal from './components/UpdateModal'; import { VoiceIndicator, VoiceInstallModal } from './components/VoiceSettings'; import { syncWakeMode } from './voice'; +import { useCompactLayout } from './useCompactLayout'; // Modals and widgets load on demand to keep startup fast. const SettingsModal = lazy(() => import('./components/SettingsModal')); -const WelcomePopup = lazy(() => import('./components/WelcomePopup')); const SpotifyWidget = lazy(() => import('./components/SpotifyWidget')); -const SetupModal = lazy(() => import('./components/SetupModal')); const MobileConnectModal = lazy(() => import('./components/MobileConnectModal')); -const WELCOME_POPUP_STORAGE_KEY = 'agentWorkspace.welcomePopupDismissed'; +const SIDEBAR_STORAGE_KEY = 'agentWorkspace.sidebarCollapsed'; function AppLayout() { const { theme, customThemes, - hasSetupCompleted, sidebarTransparency, surfaceTransparency, spotifyWidgetEnabled, @@ -41,35 +38,44 @@ function AppLayout() { mobileConnectionConfig, setMobileConnectionConfig, setMobileMode, + mainView, + selectedChatId, + selectedProject, } = useAppContext(); const [showSettings, setShowSettings] = useState(false); - const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - const [showMobileConnect, setShowMobileConnect] = useState(false); - const [showWelcomePopup, setShowWelcomePopup] = useState(() => { + const compact = useCompactLayout() || mobileMode; + // On phones and tablets the sidebar is a drawer that starts closed. + const [drawerOpen, setDrawerOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { try { - return localStorage.getItem(WELCOME_POPUP_STORAGE_KEY) !== 'true'; + return localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true'; } catch { - return true; + return false; } }); - const [showNewsPopup, setShowNewsPopup] = useState(shouldShowNewsPopup); - - const closeNewsPopup = () => { - setShowNewsPopup(false); - dismissNewsPopup(); + const sidebarOpen = compact ? drawerOpen : !sidebarCollapsed; + const toggleSidebar = () => { + if (compact) return setDrawerOpen((open) => !open); + setSidebarCollapsed((collapsed) => { + try { + localStorage.setItem(SIDEBAR_STORAGE_KEY, String(!collapsed)); + } catch { + // localStorage may be unavailable in restricted render contexts. + } + return !collapsed; + }); }; + const [showMobileConnect, setShowMobileConnect] = useState(false); + // Close the drawer after navigating somewhere from it. + useEffect(() => { + setDrawerOpen(false); + }, [mainView, selectedChatId, selectedProject?.id, compact]); // Start the always-on wake word listener if it is enabled. useEffect(() => { void syncWakeMode(); }, []); - // Re-check news popup when setup completes later in the session - useEffect(() => { - if (hasSetupCompleted && shouldShowNewsPopup()) { - setShowNewsPopup(true); - } - }, [hasSetupCompleted]); const selectedCustomTheme = customThemes.find((item) => item.id === theme); // Media-only themes keep the look of their base theme and only swap the background. const baseThemeId = selectedCustomTheme?.mediaOnly ? selectedCustomTheme.baseTheme || 'modern-dark' : theme; @@ -79,15 +85,6 @@ function AppLayout() { const backdropTheme = selectedCustomTheme?.mediaOnly ? selectedCustomTheme : customTheme; const cssTheme = customTheme ? 'custom' : baseThemeId; - const closeWelcomePopup = () => { - setShowWelcomePopup(false); - try { - localStorage.setItem(WELCOME_POPUP_STORAGE_KEY, 'true'); - } catch { - // localStorage may be unavailable in restricted render contexts. - } - }; - const bgClass = { 'modern-dark': 'bg-gradient-to-br from-[#1c181a] via-[#111111] to-[#0a0a0a]', 'classic-light': 'bg-gradient-to-br from-[#f5f2ed] via-[#e5e1d8] to-[#d1ccc0]', @@ -190,51 +187,41 @@ function AppLayout() { > {backdropTheme && }
- setShowSettings(true)} /> + setShowSettings(true)} + sidebarOpen={sidebarOpen} + onToggleSidebar={toggleSidebar} + />
- {/* Mobile Mode: Overlay sidebar toggle button */} - {mobileMode && ( - + {compact ? ( + + {drawerOpen && ( + <> + setDrawerOpen(false)} + /> + + setShowSettings(true)} /> + + + )} + + ) : ( + sidebarOpen && setShowSettings(true)} /> )} - {/* Sidebar: hidden when mobile mode is on and sidebar is collapsed */} -
- {isMobileMode && mobileSidebarOpen && ( -
setMobileSidebarOpen(false)} - /> - )} -
- setShowSettings(true)} /> -
-
- - {hasSetupCompleted && showNewsPopup && ( - - )} - - - {hasSetupCompleted && !showNewsPopup && showWelcomePopup && ( - - )} - {spotifyWidgetEnabled && } @@ -244,9 +231,6 @@ function AppLayout() { - - {!hasSetupCompleted && } - {/* Mobile Mode: Connect modal */} diff --git a/src/AppContext.tsx b/src/AppContext.tsx index 23f7a1a..90b2486 100644 --- a/src/AppContext.tsx +++ b/src/AppContext.tsx @@ -890,6 +890,25 @@ export function AppProvider({ children }: { children: ReactNode }) { useEffect(() => writeStorage('terminalStartCommand', terminalStartCommand), [terminalStartCommand]); useEffect(() => writeStorage('cliHarnesses', cliHarnesses), [cliHarnesses]); useEffect(() => writeStorage('agyAccounts', agyAccounts), [agyAccounts]); + // CodeForge Web: the accounts live on the server (shared with the codeforge CLI), not in this browser. + const agyAccountsLoaded = useRef(false); + useEffect(() => { + const workspace = window.agentWorkspace; + if (!workspace?.isWeb || !workspace.listAgyAccounts) return; + workspace + .listAgyAccounts() + .then((accounts) => { + agyAccountsLoaded.current = true; + setAgyAccounts(accounts); + }) + .catch(() => {}); + }, []); + useEffect(() => { + const workspace = window.agentWorkspace; + if (!workspace?.isWeb || !workspace.saveAgyAccounts || !agyAccountsLoaded.current) return; + const timer = setTimeout(() => void workspace.saveAgyAccounts?.(agyAccounts).catch(() => {}), 500); + return () => clearTimeout(timer); + }, [agyAccounts]); useEffect(() => writeStorage('terminalPrefixSuffixEnabled', terminalPrefixSuffixEnabled), [terminalPrefixSuffixEnabled]); useEffect(() => writeStorage('terminalPrefix', terminalPrefix), [terminalPrefix]); useEffect(() => writeStorage('terminalSuffix', terminalSuffix), [terminalSuffix]); diff --git a/src/components/AccountMenu.tsx b/src/components/AccountMenu.tsx index 04ac540..71ed993 100644 --- a/src/components/AccountMenu.tsx +++ b/src/components/AccountMenu.tsx @@ -2,6 +2,7 @@ import { useState, type ReactNode } from 'react'; import { BookOpen, CircleUserRound, Loader2, LogOut, Megaphone, MessageSquare, Play, Settings } from 'lucide-react'; import { PROVIDER_MODELS, useAppContext } from '../AppContext'; import TutorialModal from './TutorialModal'; +import NewsPopup from './NewsPopup'; export default function AccountMenu({ onSettingsClick, @@ -26,6 +27,7 @@ export default function AccountMenu({ const [busy, setBusy] = useState<'limit' | null>(null); const [status, setStatus] = useState(''); const [showTutorial, setShowTutorial] = useState(false); + const [showNews, setShowNews] = useState(false); const providerInfo = PROVIDER_INFO[provider]; return ( @@ -139,14 +141,7 @@ export default function AccountMenu({ } label="Was ist neu" - onClick={() => { - if (window.confirm('Seite neu laden um das News-Popup erneut anzuzeigen?')) { - try { - localStorage.removeItem('agentWorkspace.newsPopupDismissed'); - } catch {} - window.location.reload(); - } - }} + onClick={() => setShowNews(true)} /> } @@ -159,6 +154,7 @@ export default function AccountMenu({
{showTutorial && setShowTutorial(false)} />} + {showNews && setShowNews(false)} />} ); } diff --git a/src/components/MainArea.tsx b/src/components/MainArea.tsx index 89165a3..c488b75 100644 --- a/src/components/MainArea.tsx +++ b/src/components/MainArea.tsx @@ -42,6 +42,7 @@ import { harnessIcon } from '../harnessIcons'; import { ApiChatSetup, ApiChatView } from './ApiChat'; import { useDictation } from '../useDictation'; import { useVoiceSettings } from '../voice'; +import { useCompactLayout } from '../useCompactLayout'; import { AddVServerDialog, NewTabMenu, VServerPanel, useVServers } from './VServers'; import { SftpBrowser } from './SftpBrowser'; import { isVideoPath, toFileUrl } from '../media'; @@ -1095,7 +1096,7 @@ function TerminalInstance({ } // Start PTY session in backend - window.agentWorkspace.createShellSession({ + const shellCreated = window.agentWorkspace.createShellSession({ chatId: tabId, cwd: startPath, shellType, @@ -1104,50 +1105,63 @@ function TerminalInstance({ agyAccountId: tab?.agyAccountId, }); - // SSH login takes longer than a local shell before a command can be typed. - const commandDelay = sshServer ? 3500 : 1000; - if (tab?.command || tab?.blank) { - if (tab.command && !startedTerminalTabs.has(tabId)) { - startedTerminalTabs.add(tabId); - const tabCommand = tab.command; - setTimeout(() => { - window.agentWorkspace.writeToShellSession({ chatId: tabId, text: tabCommand + '\r' }); - }, commandDelay); - } - } else if (sshServer) { - // Plain SSH session: no local start command. - } else if (chat.mode === 'standard') { - const agentCmd = platformStartCommands[provider] || getAgentCommand(provider); - const waitTime = platformWaitTimes[provider] || 5000; - if (!startedTerminalTabs.has(tabId)) { - startedTerminalTabs.add(tabId); - setTimeout(() => { - window.agentWorkspace.writeToShellSession({ chatId: tabId, text: agentCmd + '\r' }); + const runStartCommands = () => { + // SSH login takes longer than a local shell before a command can be typed. + const commandDelay = sshServer ? 3500 : 1000; + if (tab?.command || tab?.blank) { + if (tab.command && !startedTerminalTabs.has(tabId)) { + startedTerminalTabs.add(tabId); + const tabCommand = tab.command; + setTimeout(() => { + window.agentWorkspace.writeToShellSession({ chatId: tabId, text: tabCommand + '\r' }); + }, commandDelay); + } + } else if (sshServer) { + // Plain SSH session: no local start command. + } else if (chat.mode === 'standard') { + const agentCmd = platformStartCommands[provider] || getAgentCommand(provider); + const waitTime = platformWaitTimes[provider] || 5000; + if (!startedTerminalTabs.has(tabId)) { + startedTerminalTabs.add(tabId); + setTimeout(() => { + window.agentWorkspace.writeToShellSession({ chatId: tabId, text: agentCmd + '\r' }); - const initialPrompt = chat.messages.find(m => m.sender === 'user')?.text; - if (initialPrompt) { - setTimeout(() => { - window.agentWorkspace.writeToShellSession({ chatId: tabId, text: initialPrompt + '\r' }); - }, waitTime); - } - }, 1000); - } - } else if (chat.harness) { - if (chat.harness.command && !startedTerminalTabs.has(tabId)) { - startedTerminalTabs.add(tabId); - const harnessCommand = chat.harness.command; - setTimeout(() => { - window.agentWorkspace.writeToShellSession({ chatId: tabId, text: harnessCommand + '\r' }); - }, 1000); - } - } else { - if (terminalStartCommandEnabled && terminalStartCommand && !startedTerminalTabs.has(tabId)) { - startedTerminalTabs.add(tabId); - setTimeout(() => { - window.agentWorkspace.writeToShellSession({ chatId: tabId, text: terminalStartCommand + '\r' }); - }, 1000); + const initialPrompt = chat.messages.find(m => m.sender === 'user')?.text; + if (initialPrompt) { + setTimeout(() => { + window.agentWorkspace.writeToShellSession({ chatId: tabId, text: initialPrompt + '\r' }); + }, waitTime); + } + }, 1000); + } + } else if (chat.harness) { + if (chat.harness.command && !startedTerminalTabs.has(tabId)) { + startedTerminalTabs.add(tabId); + const harnessCommand = chat.harness.command; + setTimeout(() => { + window.agentWorkspace.writeToShellSession({ chatId: tabId, text: harnessCommand + '\r' }); + }, 1000); + } + } else { + if (terminalStartCommandEnabled && terminalStartCommand && !startedTerminalTabs.has(tabId)) { + startedTerminalTabs.add(tabId); + setTimeout(() => { + window.agentWorkspace.writeToShellSession({ chatId: tabId, text: terminalStartCommand + '\r' }); + }, 1000); + } } - } + + }; + // CodeForge Web re-attaches running sessions; their program must not be started a second time. + Promise.resolve(shellCreated) + .then((result) => { + if (result && result.skipStartCommand) { + startedTerminalTabs.add(tabId); + return; + } + runStartCommands(); + }) + .catch(() => runStartCommands()); // Handle incoming output from backend PTY const unsubscribe = window.agentWorkspace.onShellOutput(tabId, (payload: any) => { @@ -1250,6 +1264,38 @@ function TerminalInstance({ }; window.addEventListener('resize', handleResize); + // xterm.js has no touch scrolling, so vertical swipes become wheel events. With tmux + // mouse mode on they scroll the session history; a plain tap still focuses the terminal. + const touchTarget = terminalRef.current; + let lastTouchY: number | null = null; + const onTouchStart = (event: TouchEvent) => { + lastTouchY = event.touches.length === 1 ? event.touches[0].clientY : null; + }; + const onTouchMove = (event: TouchEvent) => { + if (lastTouchY === null || event.touches.length !== 1) return; + const touch = event.touches[0]; + const deltaY = lastTouchY - touch.clientY; + if (Math.abs(deltaY) < 4) return; + lastTouchY = touch.clientY; + event.preventDefault(); + term.element?.dispatchEvent( + new WheelEvent('wheel', { + deltaY: deltaY * 2, + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + clientX: touch.clientX, + clientY: touch.clientY, + bubbles: true, + cancelable: true, + }), + ); + }; + const onTouchEnd = () => { + lastTouchY = null; + }; + touchTarget?.addEventListener('touchstart', onTouchStart, { passive: true }); + touchTarget?.addEventListener('touchmove', onTouchMove, { passive: false }); + touchTarget?.addEventListener('touchend', onTouchEnd); + const timer = setTimeout(handleResize, 100); return () => { @@ -1258,6 +1304,9 @@ function TerminalInstance({ unsubscribe(); term.dispose(); window.removeEventListener('resize', handleResize); + touchTarget?.removeEventListener('touchstart', onTouchStart); + touchTarget?.removeEventListener('touchmove', onTouchMove); + touchTarget?.removeEventListener('touchend', onTouchEnd); }; }, [tabId, startPath]); @@ -1322,6 +1371,7 @@ function TerminalChatView({ chat }: { chat: Chat }) { setTerminalPrefixSuffixEnabled, openBrowserTab, cliHarnesses, + mobileMode, } = useAppContext(); const [isFullscreen, setIsFullscreen] = useState(false); const [editingTabId, setEditingTabId] = useState(null); @@ -1331,6 +1381,8 @@ function TerminalChatView({ chat }: { chat: Chat }) { const tabs = currentChat.terminalTabs || [{ id: chat.id, title: 'Terminal 1' }]; const activeTabId = currentChat.activeTerminalTabId || chat.id; const terminalLayout = currentChat.terminalLayout || 'single'; + // Phones and tablets stack grid terminals in one scrollable column. + const stacked = useCompactLayout() || mobileMode; const gridSize = Math.max(1, Math.min(6, currentChat.terminalGridSize || 4)); const startPath = terminalStartPath || selectedProject?.path || undefined; @@ -1772,9 +1824,9 @@ function TerminalChatView({ chat }: { chat: Chat }) {
{terminalLayout === 'grid' ? ( -
+
{tabs.slice(0, gridSize).map((tab) => ( -
+
{tab.title} {tab.shellType || 'cmd'} @@ -1791,7 +1843,7 @@ function TerminalChatView({ chat }: { chat: Chat }) {
))} - {tabs.length < gridSize && Array.from({ length: gridSize - tabs.length }).map((_, i) => ( + {!stacked && tabs.length < gridSize && Array.from({ length: gridSize - tabs.length }).map((_, i) => (
Kein Tab - -
-
-