Files
automaker/apps/ui/src/components/views/terminal-view.tsx
Shirone 69f3ba9724 feat: standardize logging across UI components
- Replaced console.log and console.error statements with logger methods from @automaker/utils in various UI components, ensuring consistent log formatting and improved readability.
- Enhanced error handling by utilizing logger methods to provide clearer context for issues encountered during operations.
- Updated multiple views and hooks to integrate the new logging system, improving maintainability and debugging capabilities.

This update significantly enhances the observability of UI components, facilitating easier troubleshooting and monitoring.
2026-01-02 17:33:15 +01:00

1636 lines
58 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Terminal as TerminalIcon,
Plus,
Lock,
Unlock,
SplitSquareHorizontal,
SplitSquareVertical,
Loader2,
AlertCircle,
RefreshCw,
X,
SquarePlus,
Settings,
} from 'lucide-react';
import { getServerUrlSync } from '@/lib/http-api-client';
import {
useAppStore,
type TerminalPanelContent,
type TerminalTab,
type PersistedTerminalPanel,
} from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { TERMINAL_FONT_OPTIONS } from '@/config/terminal-themes';
import { toast } from 'sonner';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { TerminalPanel } from './terminal-view/terminal-panel';
import { TerminalErrorBoundary } from './terminal-view/terminal-error-boundary';
import {
DndContext,
DragEndEvent,
DragStartEvent,
DragOverEvent,
PointerSensor,
TouchSensor,
KeyboardSensor,
useSensor,
useSensors,
closestCenter,
DragOverlay,
useDroppable,
useDraggable,
defaultDropAnimationSideEffects,
} from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { apiFetch, apiGet, apiPost, apiDeleteRaw, getAuthHeaders } from '@/lib/api-fetch';
import { getApiKey } from '@/lib/http-api-client';
const logger = createLogger('Terminal');
interface TerminalStatus {
enabled: boolean;
passwordRequired: boolean;
platform: {
platform: string;
isWSL: boolean;
defaultShell: string;
arch: string;
};
}
// Tab component with drag-drop support and double-click to rename
function TerminalTabButton({
tab,
isActive,
onClick,
onClose,
onRename,
isDropTarget,
isDraggingTab,
}: {
tab: TerminalTab;
isActive: boolean;
onClick: () => void;
onClose: () => void;
onRename: (newName: string) => void;
isDropTarget: boolean;
isDraggingTab: boolean;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(tab.name);
const inputRef = useRef<HTMLInputElement>(null);
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `tab-${tab.id}`,
data: { type: 'tab', tabId: tab.id },
});
const {
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef: setDragRef,
isDragging,
} = useDraggable({
id: `drag-tab-${tab.id}`,
data: { type: 'drag-tab', tabId: tab.id },
});
// Combine refs
const setRefs = (node: HTMLDivElement | null) => {
setDropRef(node);
setDragRef(node);
};
// Focus input when entering edit mode
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const handleDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation();
setEditName(tab.name);
setIsEditing(true);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter') {
e.preventDefault();
finishEditing();
} else if (e.key === 'Escape') {
e.preventDefault();
setIsEditing(false);
setEditName(tab.name);
}
};
const finishEditing = () => {
const trimmedName = editName.trim();
if (trimmedName && trimmedName !== tab.name) {
onRename(trimmedName);
}
setIsEditing(false);
};
return (
<div
ref={setRefs}
{...dragAttributes}
{...dragListeners}
className={cn(
'flex items-center gap-1 px-3 py-1.5 text-sm rounded-t-md border-b-2 cursor-grab active:cursor-grabbing transition-colors select-none',
isActive
? 'bg-background border-brand-500 text-foreground'
: 'bg-muted border-transparent text-muted-foreground hover:text-foreground hover:bg-accent',
isOver && isDropTarget && isDraggingTab && 'ring-2 ring-blue-500 bg-blue-500/10',
isDragging && 'opacity-50'
)}
onClick={onClick}
onDoubleClick={handleDoubleClick}
>
<TerminalIcon className="h-3 w-3" />
{isEditing ? (
<input
ref={inputRef}
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={finishEditing}
onClick={(e) => e.stopPropagation()}
className="w-20 px-1 py-0 text-sm bg-background border border-border rounded focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
) : (
<span className="max-w-24 truncate">{tab.name}</span>
)}
<button
className="ml-1 p-0.5 rounded hover:bg-accent text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
}
// New tab drop zone
function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) {
const { setNodeRef, isOver } = useDroppable({
id: 'new-tab-zone',
data: { type: 'new-tab' },
});
return (
<div
ref={setNodeRef}
className={cn(
'flex items-center justify-center px-3 py-1.5 rounded-t-md border-2 border-dashed transition-all',
isOver && isDropTarget
? 'border-green-500 bg-green-500/10 text-green-500'
: 'border-transparent text-muted-foreground hover:border-border'
)}
>
<SquarePlus className="h-4 w-4" />
</div>
);
}
export function TerminalView() {
const {
terminalState,
setTerminalUnlocked,
addTerminalToLayout,
removeTerminalFromLayout,
setActiveTerminalSession,
swapTerminals,
currentProject,
addTerminalTab,
removeTerminalTab,
setActiveTerminalTab,
renameTerminalTab,
reorderTerminalTabs,
moveTerminalToTab,
setTerminalPanelFontSize,
setTerminalTabLayout,
toggleTerminalMaximized,
saveTerminalLayout,
getPersistedTerminalLayout,
clearTerminalState,
setTerminalDefaultFontSize,
setTerminalDefaultRunScript,
setTerminalFontFamily,
setTerminalLineHeight,
updateTerminalPanelSizes,
} = useAppStore();
const [status, setStatus] = useState<TerminalStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [password, setPassword] = useState('');
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
const [dragOverTabId, setDragOverTabId] = useState<string | null>(null);
const lastCreateTimeRef = useRef<number>(0);
const isCreatingRef = useRef<boolean>(false);
const restoringProjectPathRef = useRef<string | null>(null);
const [newSessionIds, setNewSessionIds] = useState<Set<string>>(new Set());
const [serverSessionInfo, setServerSessionInfo] = useState<{
current: number;
max: number;
} | null>(null);
const hasShownHighRamWarningRef = useRef<boolean>(false);
// Show warning when 20+ terminals are open
useEffect(() => {
if (
serverSessionInfo &&
serverSessionInfo.current >= 20 &&
!hasShownHighRamWarningRef.current
) {
hasShownHighRamWarningRef.current = true;
toast.warning('Many terminals open', {
description: `${serverSessionInfo.current} terminals open. Each uses system resources (processes, memory). Consider closing unused terminals.`,
duration: 8000,
});
}
// Reset warning flag when session count drops below 20
if (serverSessionInfo && serverSessionInfo.current < 20) {
hasShownHighRamWarningRef.current = false;
}
}, [serverSessionInfo]);
// Get the default run script from terminal settings
const defaultRunScript = useAppStore((state) => state.terminalState.defaultRunScript);
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
// Helper to collect all session IDs from all tabs
const collectAllSessionIds = useCallback((): string[] => {
const sessionIds: string[] = [];
const collectFromLayout = (node: TerminalPanelContent | null): void => {
if (!node) return;
if (node.type === 'terminal') {
sessionIds.push(node.sessionId);
} else {
node.panels.forEach(collectFromLayout);
}
};
terminalState.tabs.forEach((tab) => collectFromLayout(tab.layout));
return sessionIds;
}, [terminalState.tabs]);
// Kill all terminal sessions on the server
// This should be called before clearTerminalState() to prevent orphaned server sessions
const killAllSessions = useCallback(async () => {
const sessionIds = collectAllSessionIds();
if (sessionIds.length === 0) return;
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
logger.info(`Killing ${sessionIds.length} sessions on server`);
// Kill all sessions in parallel
await Promise.allSettled(
sessionIds.map(async (sessionId) => {
try {
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
} catch (err) {
logger.error(`Failed to kill session ${sessionId}:`, err);
}
})
);
}, [collectAllSessionIds, terminalState.authToken]);
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
// Helper to check if terminal creation should be debounced
const canCreateTerminal = (debounceMessage: string): boolean => {
const now = Date.now();
if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
logger.debug(debounceMessage);
return false;
}
lastCreateTimeRef.current = now;
isCreatingRef.current = true;
return true;
};
// Get active tab
const activeTab = terminalState.tabs.find((t) => t.id === terminalState.activeTabId);
// DnD sensors with activation constraint to avoid accidental drags
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 200,
tolerance: 5,
},
}),
useSensor(KeyboardSensor)
);
// Handle drag start
const handleDragStart = useCallback((event: DragStartEvent) => {
const activeId = event.active.id as string;
const activeData = event.active.data?.current;
if (activeData?.type === 'drag-tab') {
// Tab being dragged
setActiveDragTabId(activeData.tabId);
setActiveDragId(null);
} else {
// Terminal panel being dragged
setActiveDragId(activeId);
setActiveDragTabId(null);
}
}, []);
// Handle drag over - track which tab we're hovering
const handleDragOver = useCallback((event: DragOverEvent) => {
const { over } = event;
if (over?.data?.current?.type === 'tab') {
setDragOverTabId(over.data.current.tabId);
} else if (over?.data?.current?.type === 'new-tab') {
setDragOverTabId('new');
} else {
setDragOverTabId(null);
}
}, []);
// Handle drag end
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
const activeData = active.data?.current;
// Reset drag states
setActiveDragId(null);
setActiveDragTabId(null);
setDragOverTabId(null);
if (!over) return;
const overData = over.data?.current;
// Handle tab-to-tab drag (reordering)
if (activeData?.type === 'drag-tab' && overData?.type === 'tab') {
const fromTabId = activeData.tabId as string;
const toTabId = overData.tabId as string;
if (fromTabId !== toTabId) {
reorderTerminalTabs(fromTabId, toTabId);
}
return;
}
// Handle terminal panel drops
const activeId = active.id as string;
// If dropped on a tab, move terminal to that tab
if (overData?.type === 'tab') {
moveTerminalToTab(activeId, overData.tabId);
return;
}
// If dropped on new tab zone, create new tab with this terminal
if (overData?.type === 'new-tab') {
moveTerminalToTab(activeId, 'new');
return;
}
// Otherwise, swap terminals within current tab
if (active.id !== over.id) {
swapTerminals(activeId, over.id as string);
}
},
[swapTerminals, moveTerminalToTab, reorderTerminalTabs]
);
const handleDragCancel = useCallback(() => {
setActiveDragId(null);
setActiveDragTabId(null);
setDragOverTabId(null);
}, []);
// Fetch terminal status
const fetchStatus = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await apiGet<{ success: boolean; data?: TerminalStatus; error?: string }>(
'/api/terminal/status'
);
if (data.success && data.data) {
setStatus(data.data);
if (!data.data.passwordRequired) {
setTerminalUnlocked(true);
}
} else {
setError(data.error || 'Failed to get terminal status');
}
} catch (err) {
setError('Failed to connect to server');
logger.error('Status fetch error:', err);
} finally {
setLoading(false);
}
}, [setTerminalUnlocked]);
// Fetch server session settings
const fetchServerSettings = useCallback(async () => {
if (!terminalState.isUnlocked) return;
try {
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const data = await apiGet<{
success: boolean;
data?: { currentSessions: number; maxSessions: number };
}>('/api/terminal/settings', { headers });
if (data.success && data.data) {
setServerSessionInfo({ current: data.data.currentSessions, max: data.data.maxSessions });
}
} catch (err) {
logger.error('Failed to fetch server settings:', err);
}
}, [terminalState.isUnlocked, terminalState.authToken]);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Clean up all terminal sessions when the page/app is about to close
// This prevents orphaned PTY processes on the server
useEffect(() => {
const handleBeforeUnload = () => {
// Use sendBeacon for reliable delivery during page unload
// Fall back to sync fetch if sendBeacon is not available
const sessionIds = collectAllSessionIds();
if (sessionIds.length === 0) return;
// Try to use the bulk delete endpoint if available, otherwise delete individually
// Using sync XMLHttpRequest for reliability during page unload (async doesn't complete)
sessionIds.forEach((sessionId) => {
const url = `${serverUrl}/api/terminal/sessions/${sessionId}`;
try {
const xhr = new XMLHttpRequest();
xhr.open('DELETE', url, false); // synchronous
xhr.withCredentials = true; // Include cookies for session auth
// Add API auth header
const apiKey = getApiKey();
if (apiKey) {
xhr.setRequestHeader('X-API-Key', apiKey);
}
// Add terminal-specific auth
if (terminalState.authToken) {
xhr.setRequestHeader('X-Terminal-Token', terminalState.authToken);
}
xhr.send();
} catch {
// Ignore errors during unload - best effort cleanup
}
});
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [collectAllSessionIds, terminalState.authToken, serverUrl]);
// Fetch server settings when terminal is unlocked
useEffect(() => {
if (terminalState.isUnlocked) {
fetchServerSettings();
}
}, [terminalState.isUnlocked, fetchServerSettings]);
// Handle project switching - save and restore terminal layouts
// Uses terminalState.lastActiveProjectPath (persisted in store) instead of a local ref
// This ensures terminals persist when navigating away from terminal route and back
useEffect(() => {
const currentPath = currentProject?.path || null;
// Read lastActiveProjectPath directly from store to avoid dependency issues
const prevPath = useAppStore.getState().terminalState.lastActiveProjectPath;
// Skip if no change - this now correctly handles route changes within the same project
// because lastActiveProjectPath persists in the store across component unmount/remount
if (currentPath === prevPath) {
return;
}
// If we're restoring a different project, that restore will be stale - let it finish but ignore results
// The path check in restoreLayout will handle this
// Save layout for previous project (if there was one and has terminals)
// BUT don't save if we were mid-restore for that project (would save incomplete state)
const currentTabs = useAppStore.getState().terminalState.tabs;
if (prevPath && currentTabs.length > 0 && restoringProjectPathRef.current !== prevPath) {
saveTerminalLayout(prevPath);
}
// Update the stored project path
useAppStore.getState().setTerminalLastActiveProjectPath(currentPath);
// Helper to kill sessions and clear state
const killAndClear = async () => {
// Kill all server-side sessions first to prevent orphaned processes
await killAllSessions();
clearTerminalState();
};
// If no current project, just clear terminals
if (!currentPath) {
killAndClear();
return;
}
// ALWAYS clear existing terminals when switching projects
// This is critical - prevents old project's terminals from "bleeding" into new project
// We need to kill server sessions first to prevent orphans
killAndClear();
// Check for saved layout for this project
const savedLayout = getPersistedTerminalLayout(currentPath);
// If no saved layout or no tabs, we're done - terminal starts fresh for this project
if (!savedLayout || savedLayout.tabs.length === 0) {
logger.info('No saved layout for project, starting fresh');
return;
}
// Restore the saved layout - try to reconnect to existing sessions
// Track which project we're restoring to detect stale restores
restoringProjectPathRef.current = currentPath;
// Create terminals and build layout - try to reconnect or create new
const restoreLayout = async () => {
// Check if we're still restoring the same project (user may have switched)
if (restoringProjectPathRef.current !== currentPath) {
logger.info('Restore cancelled - project changed');
return;
}
let failedSessions = 0;
let totalSessions = 0;
let reconnectedSessions = 0;
try {
const headers: Record<string, string> = {};
// Get fresh auth token from store
const authToken = useAppStore.getState().terminalState.authToken;
if (authToken) {
headers['X-Terminal-Token'] = authToken;
}
// Helper to check if a session still exists on server
const checkSessionExists = async (sessionId: string): Promise<boolean> => {
try {
const data = await apiGet<{ success: boolean }>(`/api/terminal/sessions/${sessionId}`, {
headers,
});
return data.success === true;
} catch {
return false;
}
};
// Helper to create a new terminal session
const createSession = async (): Promise<string | null> => {
try {
const data = await apiPost<{ success: boolean; data?: { id: string } }>(
'/api/terminal/sessions',
{ cwd: currentPath, cols: 80, rows: 24 },
{ headers }
);
return data.success && data.data ? data.data.id : null;
} catch (err) {
logger.error('Failed to create terminal session:', err);
return null;
}
};
// Recursively rebuild the layout - reuse existing sessions or create new
const rebuildLayout = async (
persisted: PersistedTerminalPanel
): Promise<TerminalPanelContent | null> => {
if (persisted.type === 'terminal') {
totalSessions++;
let sessionId: string | null = null;
// If we have a saved sessionId, try to reconnect to it
if (persisted.sessionId) {
const exists = await checkSessionExists(persisted.sessionId);
if (exists) {
sessionId = persisted.sessionId;
reconnectedSessions++;
}
}
// If no saved session or it's gone, create a new one
if (!sessionId) {
sessionId = await createSession();
}
if (!sessionId) {
failedSessions++;
return null;
}
return {
type: 'terminal',
sessionId,
size: persisted.size,
fontSize: persisted.fontSize,
};
}
// It's a split - rebuild all child panels
const childPanels: TerminalPanelContent[] = [];
for (const childPersisted of persisted.panels) {
const rebuilt = await rebuildLayout(childPersisted);
if (rebuilt) {
childPanels.push(rebuilt);
}
}
// If no children were rebuilt, return null
if (childPanels.length === 0) return null;
// If only one child, return it directly (collapse the split)
if (childPanels.length === 1) return childPanels[0];
return {
type: 'split',
id: persisted.id || `split-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
direction: persisted.direction,
panels: childPanels,
size: persisted.size,
};
};
// For each saved tab, rebuild the layout
for (let tabIndex = 0; tabIndex < savedLayout.tabs.length; tabIndex++) {
// Check if project changed during restore - bail out early
if (restoringProjectPathRef.current !== currentPath) {
logger.info('Restore cancelled mid-loop - project changed');
return;
}
const savedTab = savedLayout.tabs[tabIndex];
// Create the tab first
const newTabId = addTerminalTab(savedTab.name);
if (savedTab.layout) {
const rebuiltLayout = await rebuildLayout(savedTab.layout);
if (rebuiltLayout) {
const { setTerminalTabLayout } = useAppStore.getState();
setTerminalTabLayout(newTabId, rebuiltLayout);
}
}
}
// Set active tab based on saved index
if (savedLayout.tabs.length > 0) {
const { setActiveTerminalTab } = useAppStore.getState();
const newTabs = useAppStore.getState().terminalState.tabs;
if (newTabs.length > savedLayout.activeTabIndex) {
setActiveTerminalTab(newTabs[savedLayout.activeTabIndex].id);
}
}
if (failedSessions > 0) {
toast.error('Some terminals failed to restore', {
description: `${failedSessions} of ${totalSessions} terminal sessions could not be created. The server may be unavailable.`,
duration: 5000,
});
} else if (reconnectedSessions > 0) {
toast.success('Terminals restored', {
description: `Reconnected to ${reconnectedSessions} existing session${reconnectedSessions > 1 ? 's' : ''}`,
duration: 3000,
});
}
} catch (err) {
logger.error('Failed to restore terminal layout:', err);
toast.error('Failed to restore terminals', {
description: 'Could not restore terminal layout. Please try creating new terminals.',
duration: 5000,
});
} finally {
// Only clear if we're still the active restore
if (restoringProjectPathRef.current === currentPath) {
restoringProjectPathRef.current = null;
}
}
};
restoreLayout();
}, [
currentProject?.path,
saveTerminalLayout,
getPersistedTerminalLayout,
clearTerminalState,
addTerminalTab,
serverUrl,
killAllSessions,
]);
// Save terminal layout whenever it changes (debounced to prevent excessive writes)
// Also save when tabs become empty so closed terminals stay closed on refresh
const saveLayoutTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pendingSavePathRef = useRef<string | null>(null);
useEffect(() => {
const projectPath = currentProject?.path;
// Don't save while restoring this project's layout
if (projectPath && restoringProjectPathRef.current !== projectPath) {
// Debounce saves to prevent excessive localStorage writes during rapid changes
if (saveLayoutTimeoutRef.current) {
clearTimeout(saveLayoutTimeoutRef.current);
}
// Capture the project path at schedule time so we save to the correct project
// even if user switches projects before the timeout fires
pendingSavePathRef.current = projectPath;
saveLayoutTimeoutRef.current = setTimeout(() => {
// Only save if we're still on the same project
if (pendingSavePathRef.current === projectPath) {
saveTerminalLayout(projectPath);
}
pendingSavePathRef.current = null;
saveLayoutTimeoutRef.current = null;
}, 500); // 500ms debounce
}
return () => {
if (saveLayoutTimeoutRef.current) {
clearTimeout(saveLayoutTimeoutRef.current);
}
};
}, [terminalState.tabs, currentProject?.path, saveTerminalLayout]);
// Handle password authentication
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault();
setAuthLoading(true);
setAuthError(null);
try {
const data = await apiPost<{ success: boolean; data?: { token: string }; error?: string }>(
'/api/terminal/auth',
{ password }
);
if (data.success && data.data) {
setTerminalUnlocked(true, data.data.token);
setPassword('');
} else {
setAuthError(data.error || 'Authentication failed');
}
} catch (err) {
setAuthError('Failed to authenticate');
logger.error('Auth error:', err);
} finally {
setAuthLoading(false);
}
};
// Create a new terminal session
// targetSessionId: the terminal to split (if splitting an existing terminal)
const createTerminal = async (
direction?: 'horizontal' | 'vertical',
targetSessionId?: string
) => {
if (!canCreateTerminal('[Terminal] Debounced terminal creation')) {
return;
}
try {
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers,
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
});
const data = await response.json();
if (data.success) {
addTerminalToLayout(data.data.id, direction, targetSessionId);
// Mark this session as new for running initial command
if (defaultRunScript) {
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
}
// Refresh session count
fetchServerSettings();
} else {
// Handle session limit error with a helpful toast
if (response.status === 429 || data.error?.includes('Maximum')) {
toast.error('Terminal session limit reached', {
description:
data.details ||
`Please close unused terminals. Limit: ${data.maxSessions || 'unknown'}`,
});
} else {
logger.error('Failed to create session:', data.error);
toast.error('Failed to create terminal', {
description: data.error || 'Unknown error',
});
}
}
} catch (err) {
logger.error('Create session error:', err);
toast.error('Failed to create terminal', {
description: 'Could not connect to server',
});
} finally {
isCreatingRef.current = false;
}
};
// Create terminal in new tab
const createTerminalInNewTab = async () => {
if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) {
return;
}
const tabId = addTerminalTab();
try {
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers,
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
});
const data = await response.json();
if (data.success) {
// Add to the newly created tab
const { addTerminalToTab } = useAppStore.getState();
addTerminalToTab(data.data.id, tabId);
// Mark this session as new for running initial command
if (defaultRunScript) {
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
}
// Refresh session count
fetchServerSettings();
} else {
// Remove the empty tab that was created
const { removeTerminalTab } = useAppStore.getState();
removeTerminalTab(tabId);
// Handle session limit error with a helpful toast
if (response.status === 429 || data.error?.includes('Maximum')) {
toast.error('Terminal session limit reached', {
description:
data.details ||
`Please close unused terminals. Limit: ${data.maxSessions || 'unknown'}`,
});
} else {
toast.error('Failed to create terminal', {
description: data.error || 'Unknown error',
});
}
}
} catch (err) {
logger.error('Create session error:', err);
// Remove the empty tab on error
const { removeTerminalTab } = useAppStore.getState();
removeTerminalTab(tabId);
toast.error('Failed to create terminal', {
description: 'Could not connect to server',
});
} finally {
isCreatingRef.current = false;
}
};
// Kill a terminal session
const killTerminal = async (sessionId: string) => {
try {
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
// Always remove from UI - even if server says 404 (session may have already exited)
removeTerminalFromLayout(sessionId);
if (!response.ok && response.status !== 404) {
// Log non-404 errors but still proceed with UI cleanup
const data = await response.json().catch(() => ({}));
logger.error('Server failed to kill session:', data.error || response.statusText);
}
// Refresh session count
fetchServerSettings();
} catch (err) {
logger.error('Kill session error:', err);
// Still remove from UI on network error - better UX than leaving broken terminal
removeTerminalFromLayout(sessionId);
}
};
// Kill all terminals in a tab and then remove the tab
const killTerminalTab = async (tabId: string) => {
const tab = terminalState.tabs.find((t) => t.id === tabId);
if (!tab) return;
// Collect all session IDs from the tab's layout
const collectSessionIds = (node: TerminalPanelContent | null): string[] => {
if (!node) return [];
if (node.type === 'terminal') return [node.sessionId];
return node.panels.flatMap(collectSessionIds);
};
const sessionIds = collectSessionIds(tab.layout);
// Kill all sessions on the server
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
await Promise.all(
sessionIds.map(async (sessionId) => {
try {
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
} catch (err) {
logger.error(`Failed to kill session ${sessionId}:`, err);
}
})
);
// Now remove the tab from state
removeTerminalTab(tabId);
// Refresh session count
fetchServerSettings();
};
// NOTE: Terminal keyboard shortcuts (Alt+D, Alt+S, Alt+W) are handled in
// terminal-panel.tsx via attachCustomKeyEventHandler. This is more reliable
// because it uses event.code (keyboard-layout independent) instead of event.key
// which can produce special characters when Alt is pressed on some systems.
// See: terminal-panel.tsx lines 319-399 for the shortcut handlers.
// Collect all terminal IDs from a panel tree in order
const getTerminalIds = (panel: TerminalPanelContent): string[] => {
if (panel.type === 'terminal') {
return [panel.sessionId];
}
return panel.panels.flatMap(getTerminalIds);
};
// Get a STABLE key for a panel - uses the stable id for splits
// This prevents unnecessary remounts when layout structure changes
const getPanelKey = (panel: TerminalPanelContent): string => {
if (panel.type === 'terminal') {
return panel.sessionId;
}
// Use the stable id for split nodes
return panel.id;
};
const findTerminalFontSize = useCallback(
(sessionId: string): number => {
const findInPanel = (panel: TerminalPanelContent): number | null => {
if (panel.type === 'terminal') {
if (panel.sessionId === sessionId) {
return panel.fontSize ?? terminalState.defaultFontSize;
}
return null;
}
for (const child of panel.panels) {
const found = findInPanel(child);
if (found !== null) return found;
}
return null;
};
// Search across all tabs
for (const tab of terminalState.tabs) {
if (tab.layout) {
const found = findInPanel(tab.layout);
if (found !== null) return found;
}
}
return terminalState.defaultFontSize;
},
[terminalState.tabs, terminalState.defaultFontSize]
);
// Handler for when a terminal has run its initial command
const handleCommandRan = useCallback((sessionId: string) => {
setNewSessionIds((prev) => {
const next = new Set(prev);
next.delete(sessionId);
return next;
});
}, []);
// Navigate between terminal panes with directional awareness
// Arrow keys navigate in the actual spatial direction within the layout
const navigateToTerminal = useCallback(
(direction: 'up' | 'down' | 'left' | 'right') => {
if (!activeTab?.layout) return;
const currentSessionId = terminalState.activeSessionId;
if (!currentSessionId) {
// If no terminal is active, focus the first one
const terminalIds = getTerminalIds(activeTab.layout);
if (terminalIds.length > 0) {
setActiveTerminalSession(terminalIds[0]);
}
return;
}
// Find the terminal in the given direction
// The algorithm traverses the layout tree to find spatially adjacent terminals
const findTerminalInDirection = (
layout: TerminalPanelContent,
targetId: string,
dir: 'up' | 'down' | 'left' | 'right'
): string | null => {
// Helper to get all terminal IDs from a layout subtree
const getAllTerminals = (node: TerminalPanelContent): string[] => {
if (node.type === 'terminal') return [node.sessionId];
return node.panels.flatMap(getAllTerminals);
};
// Helper to find terminal and its path in the tree
type PathEntry = {
node: TerminalPanelContent;
index: number;
direction: 'horizontal' | 'vertical';
};
const findPath = (
node: TerminalPanelContent,
target: string,
path: PathEntry[] = []
): PathEntry[] | null => {
if (node.type === 'terminal') {
return node.sessionId === target ? path : null;
}
for (let i = 0; i < node.panels.length; i++) {
const result = findPath(node.panels[i], target, [
...path,
{ node, index: i, direction: node.direction },
]);
if (result) return result;
}
return null;
};
const path = findPath(layout, targetId);
if (!path || path.length === 0) return null;
// Determine which split direction we need based on arrow direction
// left/right navigation works in "horizontal" splits (panels side by side)
// up/down navigation works in "vertical" splits (panels stacked)
const neededDirection = dir === 'left' || dir === 'right' ? 'horizontal' : 'vertical';
const goingForward = dir === 'right' || dir === 'down';
// Walk up the path to find a split in the right direction with an adjacent panel
for (let i = path.length - 1; i >= 0; i--) {
const entry = path[i];
if (entry.direction === neededDirection) {
const siblings = entry.node.type === 'split' ? entry.node.panels : [];
const nextIndex = goingForward ? entry.index + 1 : entry.index - 1;
if (nextIndex >= 0 && nextIndex < siblings.length) {
// Found an adjacent panel in the right direction
const adjacentPanel = siblings[nextIndex];
const adjacentTerminals = getAllTerminals(adjacentPanel);
if (adjacentTerminals.length > 0) {
// When moving forward (right/down), pick the first terminal in that subtree
// When moving backward (left/up), pick the last terminal in that subtree
return goingForward
? adjacentTerminals[0]
: adjacentTerminals[adjacentTerminals.length - 1];
}
}
}
}
return null;
};
const nextTerminal = findTerminalInDirection(activeTab.layout, currentSessionId, direction);
if (nextTerminal) {
setActiveTerminalSession(nextTerminal);
}
},
[activeTab?.layout, terminalState.activeSessionId, setActiveTerminalSession]
);
// Handle global keyboard shortcuts for pane navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+Alt+Arrow (or Cmd+Alt+Arrow on Mac) for pane navigation
if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey) {
if (e.key === 'ArrowRight') {
e.preventDefault();
navigateToTerminal('right');
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
navigateToTerminal('left');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
navigateToTerminal('down');
} else if (e.key === 'ArrowUp') {
e.preventDefault();
navigateToTerminal('up');
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [navigateToTerminal]);
// Render panel content recursively
const renderPanelContent = (content: TerminalPanelContent): React.ReactNode => {
if (content.type === 'terminal') {
// Use per-terminal fontSize or fall back to default
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
// Only run command on new sessions (not restored ones)
const isNewSession = newSessionIds.has(content.sessionId);
return (
<TerminalErrorBoundary
key={`boundary-${content.sessionId}`}
sessionId={content.sessionId}
onRestart={() => {
// When terminal crashes and is restarted, recreate the session
killTerminal(content.sessionId);
createTerminal();
}}
>
<TerminalPanel
key={content.sessionId}
sessionId={content.sessionId}
authToken={terminalState.authToken}
isActive={terminalState.activeSessionId === content.sessionId}
onFocus={() => setActiveTerminalSession(content.sessionId)}
onClose={() => killTerminal(content.sessionId)}
onSplitHorizontal={() => createTerminal('horizontal', content.sessionId)}
onSplitVertical={() => createTerminal('vertical', content.sessionId)}
onNewTab={createTerminalInNewTab}
onNavigateUp={() => navigateToTerminal('up')}
onNavigateDown={() => navigateToTerminal('down')}
onNavigateLeft={() => navigateToTerminal('left')}
onNavigateRight={() => navigateToTerminal('right')}
onSessionInvalid={() => {
// Auto-remove stale session when server says it doesn't exist
// This handles cases like server restart where sessions are lost
logger.info(`Session ${content.sessionId} is invalid, removing from layout`);
killTerminal(content.sessionId);
}}
isDragging={activeDragId === content.sessionId}
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
fontSize={terminalFontSize}
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
runCommandOnConnect={isNewSession ? defaultRunScript : undefined}
onCommandRan={() => handleCommandRan(content.sessionId)}
isMaximized={terminalState.maximizedSessionId === content.sessionId}
onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
/>
</TerminalErrorBoundary>
);
}
const isHorizontal = content.direction === 'horizontal';
const defaultSizePerPanel = 100 / content.panels.length;
const handleLayoutChange = (sizes: number[]) => {
if (!activeTab) return;
const panelKeys = content.panels.map(getPanelKey);
updateTerminalPanelSizes(activeTab.id, panelKeys, sizes);
};
return (
<PanelGroup direction={content.direction} onLayout={handleLayoutChange}>
{content.panels.map((panel, index) => {
const panelSize =
panel.type === 'terminal' && panel.size ? panel.size : defaultSizePerPanel;
const panelKey = getPanelKey(panel);
return (
<React.Fragment key={panelKey}>
{index > 0 && (
<PanelResizeHandle
key={`handle-${panelKey}`}
className={
isHorizontal
? 'w-1 h-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500'
: 'h-1 w-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500'
}
/>
)}
<Panel id={panelKey} order={index} defaultSize={panelSize} minSize={30}>
{renderPanelContent(panel)}
</Panel>
</React.Fragment>
);
})}
</PanelGroup>
);
};
// Loading state
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
// Error state
if (error) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-destructive/10 mb-4">
<AlertCircle className="h-12 w-12 text-destructive" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal Unavailable</h2>
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
<Button variant="outline" onClick={fetchStatus}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
);
}
// Disabled state
if (!status?.enabled) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<TerminalIcon className="h-12 w-12 text-muted-foreground" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal Disabled</h2>
<p className="text-muted-foreground max-w-md">
Terminal access has been disabled. Set{' '}
<code className="px-1.5 py-0.5 rounded bg-muted">TERMINAL_ENABLED=true</code> in your
server .env file to enable it.
</p>
</div>
);
}
// Password gate
if (status.passwordRequired && !terminalState.isUnlocked) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<Lock className="h-12 w-12 text-muted-foreground" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal Protected</h2>
<p className="text-muted-foreground max-w-md mb-6">
Terminal access requires authentication. Enter the password to unlock.
</p>
<form onSubmit={handleAuth} className="w-full max-w-xs space-y-4">
<Input
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={authLoading}
autoFocus
/>
{authError && <p className="text-sm text-destructive">{authError}</p>}
<Button type="submit" className="w-full" disabled={authLoading || !password}>
{authLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Unlock className="h-4 w-4 mr-2" />
)}
Unlock Terminal
</Button>
</form>
{status.platform && (
<p className="text-xs text-muted-foreground mt-6">
Platform: {status.platform.platform}
{status.platform.isWSL && ' (WSL)'}
{' | '}Shell: {status.platform.defaultShell}
</p>
)}
</div>
);
}
// No terminals yet - show welcome screen
if (terminalState.tabs.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-brand-500/10 mb-4">
<TerminalIcon className="h-12 w-12 text-brand-500" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal</h2>
<p className="text-muted-foreground max-w-md mb-6">
Create a new terminal session to start executing commands.
{currentProject && (
<span className="block mt-2 text-sm">
Working directory:{' '}
<code className="px-1.5 py-0.5 rounded bg-muted">{currentProject.path}</code>
</span>
)}
</p>
<Button onClick={() => createTerminal()}>
<Plus className="h-4 w-4 mr-2" />
New Terminal
</Button>
{status?.platform && (
<p className="text-xs text-muted-foreground mt-6">
Platform: {status.platform.platform}
{status.platform.isWSL && ' (WSL)'}
{' | '}Shell: {status.platform.defaultShell}
</p>
)}
</div>
);
}
// Terminal view with tabs
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Tab bar */}
<div className="flex items-center bg-card border-b border-border px-2">
{/* Tabs */}
<div className="flex items-center gap-1 flex-1 overflow-x-auto py-1">
{terminalState.tabs.map((tab) => (
<TerminalTabButton
key={tab.id}
tab={tab}
isActive={tab.id === terminalState.activeTabId}
onClick={() => setActiveTerminalTab(tab.id)}
onClose={() => killTerminalTab(tab.id)}
onRename={(newName) => renameTerminalTab(tab.id, newName)}
isDropTarget={activeDragId !== null || activeDragTabId !== null}
isDraggingTab={activeDragTabId !== null}
/>
))}
{(activeDragId || activeDragTabId) && <NewTabDropZone isDropTarget={true} />}
{/* New tab button */}
<button
className="flex items-center justify-center p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
onClick={createTerminalInNewTab}
title="New Tab"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* Toolbar buttons */}
<div className="flex items-center gap-1 pl-2 border-l border-border">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal('horizontal')}
title="Split Right"
>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal('vertical')}
title="Split Down"
>
<SplitSquareVertical className="h-4 w-4" />
</Button>
{/* Global Terminal Settings */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
title="Terminal Settings"
>
<Settings className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium text-sm">Terminal Settings</h4>
<p className="text-xs text-muted-foreground">
Configure global terminal appearance
</p>
</div>
{/* Default Font Size */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm">Default Font Size</Label>
<span className="text-sm text-muted-foreground">
{terminalState.defaultFontSize}px
</span>
</div>
<Slider
value={[terminalState.defaultFontSize]}
min={8}
max={24}
step={1}
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
onValueCommit={() => {
toast.info('Font size changed', {
description: 'New terminals will use this size',
});
}}
/>
</div>
{/* Font Family */}
<div className="space-y-2">
<Label className="text-sm">Font Family</Label>
<select
value={terminalState.fontFamily}
onChange={(e) => {
setTerminalFontFamily(e.target.value);
toast.info('Font family changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className={cn(
'w-full px-2 py-1.5 rounded-md text-sm',
'bg-accent/50 border border-border',
'text-foreground',
'focus:outline-none focus:ring-2 focus:ring-ring'
)}
>
{TERMINAL_FONT_OPTIONS.map((font) => (
<option key={font.value} value={font.value}>
{font.label}
</option>
))}
</select>
</div>
{/* Line Height */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm">Line Height</Label>
<span className="text-sm text-muted-foreground">
{terminalState.lineHeight.toFixed(1)}
</span>
</div>
<Slider
value={[terminalState.lineHeight]}
min={1.0}
max={2.0}
step={0.1}
onValueChange={([value]) => setTerminalLineHeight(value)}
onValueCommit={() => {
toast.info('Line height changed', {
description: 'Restart terminal for changes to take effect',
});
}}
/>
</div>
{/* Default Run Script */}
<div className="space-y-2">
<Label className="text-sm">Default Run Script</Label>
<Input
value={terminalState.defaultRunScript}
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
placeholder="e.g., claude, npm run dev"
className="h-8 text-sm"
/>
<p className="text-xs text-muted-foreground">
Command to run when opening new terminals
</p>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
{/* Active tab content */}
<div className="flex-1 overflow-hidden bg-background">
{terminalState.maximizedSessionId ? (
// When a terminal is maximized, render only that terminal
<TerminalErrorBoundary
key={`boundary-maximized-${terminalState.maximizedSessionId}`}
sessionId={terminalState.maximizedSessionId}
onRestart={() => {
const sessionId = terminalState.maximizedSessionId!;
toggleTerminalMaximized(sessionId);
killTerminal(sessionId);
createTerminal();
}}
>
<TerminalPanel
key={`maximized-${terminalState.maximizedSessionId}`}
sessionId={terminalState.maximizedSessionId}
authToken={terminalState.authToken}
isActive={true}
onFocus={() => setActiveTerminalSession(terminalState.maximizedSessionId!)}
onClose={() => killTerminal(terminalState.maximizedSessionId!)}
onSplitHorizontal={() =>
createTerminal('horizontal', terminalState.maximizedSessionId!)
}
onSplitVertical={() =>
createTerminal('vertical', terminalState.maximizedSessionId!)
}
onNewTab={createTerminalInNewTab}
onSessionInvalid={() => {
const sessionId = terminalState.maximizedSessionId!;
logger.info(`Maximized session ${sessionId} is invalid, removing from layout`);
killTerminal(sessionId);
}}
isDragging={false}
isDropTarget={false}
fontSize={findTerminalFontSize(terminalState.maximizedSessionId)}
onFontSizeChange={(size) =>
setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)
}
isMaximized={true}
onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)}
/>
</TerminalErrorBoundary>
) : activeTab?.layout ? (
renderPanelContent(activeTab.layout)
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<p className="text-muted-foreground mb-4">This tab is empty</p>
<Button variant="outline" size="sm" onClick={() => createTerminal()}>
<Plus className="h-4 w-4 mr-2" />
New Terminal
</Button>
</div>
)}
</div>
</div>
{/* Drag overlay */}
<DragOverlay
dropAnimation={{
sideEffects: defaultDropAnimationSideEffects({
styles: { active: { opacity: '0.5' } },
}),
}}
zIndex={1000}
>
{activeDragId ? (
<div className="relative inline-flex items-center gap-2 px-3.5 py-2 bg-card border-2 border-brand-500 rounded-lg shadow-xl pointer-events-none overflow-hidden">
<TerminalIcon className="h-4 w-4 text-brand-500 shrink-0" />
<span className="text-sm font-medium text-foreground whitespace-nowrap">
{dragOverTabId === 'new' ? 'New tab' : dragOverTabId ? 'Move to tab' : 'Terminal'}
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}