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