diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index 52c26432..6bfb08f4 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -194,9 +194,24 @@ function createWindow(): void { mainWindow?.show(); }); - // Handle external links + // Handle external links with URL scheme allowlist for security + // Note: Terminal links now use IPC via WebLinksAddon callback, but this handler + // catches any other window.open() calls (e.g., from third-party libraries) + const ALLOWED_URL_SCHEMES = ['http:', 'https:', 'mailto:']; mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url); + try { + const url = new URL(details.url); + if (!ALLOWED_URL_SCHEMES.includes(url.protocol)) { + console.warn('[main] Blocked URL with disallowed scheme:', details.url); + return { action: 'deny' }; + } + } catch { + console.warn('[main] Blocked invalid URL:', details.url); + return { action: 'deny' }; + } + shell.openExternal(details.url).catch((error) => { + console.warn('[main] Failed to open external URL:', details.url, error); + }); return { action: 'deny' }; }); diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index 1fa2d5bf..52ebcbf5 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -661,6 +661,26 @@ export function registerTerminalHandlers( } } ); + + // Update terminal display orders after drag-drop reorder + ipcMain.handle( + IPC_CHANNELS.TERMINAL_UPDATE_DISPLAY_ORDERS, + async ( + _, + projectPath: string, + orders: Array<{ terminalId: string; displayOrder: number }> + ): Promise => { + try { + terminalManager.updateDisplayOrders(projectPath, orders); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update display orders' + }; + } + } + ); } /** diff --git a/apps/frontend/src/main/terminal-session-store.ts b/apps/frontend/src/main/terminal-session-store.ts index 34495720..cb7e8461 100644 --- a/apps/frontend/src/main/terminal-session-store.ts +++ b/apps/frontend/src/main/terminal-session-store.ts @@ -18,6 +18,8 @@ export interface TerminalSession { lastActiveAt: string; // ISO timestamp /** Associated worktree configuration (validated on restore) */ worktreeConfig?: TerminalWorktreeConfig; + /** UI display position for ordering terminals after drag-drop */ + displayOrder?: number; } /** @@ -256,11 +258,16 @@ export class TerminalSessionStore { // Update existing or add new const existingIndex = todaySessions[projectPath].findIndex(s => s.id === session.id); if (existingIndex >= 0) { + // Preserve displayOrder from existing session if not provided in incoming session + // This prevents periodic saves (which don't include displayOrder) from losing tab order + const existingSession = todaySessions[projectPath][existingIndex]; todaySessions[projectPath][existingIndex] = { ...session, // Limit output buffer size outputBuffer: session.outputBuffer.slice(-MAX_OUTPUT_BUFFER), - lastActiveAt: new Date().toISOString() + lastActiveAt: new Date().toISOString(), + // Preserve existing displayOrder if incoming session doesn't have it + displayOrder: session.displayOrder ?? existingSession.displayOrder, }; } else { todaySessions[projectPath].push({ @@ -523,6 +530,30 @@ export class TerminalSessionStore { this.save(); } + /** + * Update display orders for multiple terminals (after drag-drop reorder). + * This updates the displayOrder property for matching sessions in today's bucket. + */ + updateDisplayOrders(projectPath: string, orders: Array<{ terminalId: string; displayOrder: number }>): void { + const todaySessions = this.getTodaysSessions(); + const sessions = todaySessions[projectPath]; + if (!sessions) return; + + let hasChanges = false; + for (const { terminalId, displayOrder } of orders) { + const session = sessions.find(s => s.id === terminalId); + if (session && session.displayOrder !== displayOrder) { + session.displayOrder = displayOrder; + session.lastActiveAt = new Date().toISOString(); + hasChanges = true; + } + } + + if (hasChanges) { + this.save(); + } + } + /** * Save a terminal session asynchronously (non-blocking) * diff --git a/apps/frontend/src/main/terminal/session-handler.ts b/apps/frontend/src/main/terminal/session-handler.ts index 329ed64e..cd22f480 100644 --- a/apps/frontend/src/main/terminal/session-handler.ts +++ b/apps/frontend/src/main/terminal/session-handler.ts @@ -282,6 +282,17 @@ export function getSessionsForDate(date: string, projectPath: string): TerminalS return store.getSessionsForDate(date, projectPath); } +/** + * Update display orders for terminals after drag-drop reorder + */ +export function updateDisplayOrders( + projectPath: string, + orders: Array<{ terminalId: string; displayOrder: number }> +): void { + const store = getTerminalSessionStore(); + store.updateDisplayOrders(projectPath, orders); +} + /** * Attempt to capture Claude session ID by polling the session directory. * Uses the claim mechanism to prevent race conditions when multiple terminals diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index 5181f3f4..b05568b8 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -292,6 +292,16 @@ export class TerminalManager { return SessionHandler.getSessionsForDate(date, projectPath); } + /** + * Update display orders for terminals after drag-drop reorder + */ + updateDisplayOrders( + projectPath: string, + orders: Array<{ terminalId: string; displayOrder: number }> + ): void { + SessionHandler.updateDisplayOrders(projectPath, orders); + } + /** * Restore all sessions from a specific date */ diff --git a/apps/frontend/src/preload/api/terminal-api.ts b/apps/frontend/src/preload/api/terminal-api.ts index efd6b0c0..660eba6b 100644 --- a/apps/frontend/src/preload/api/terminal-api.ts +++ b/apps/frontend/src/preload/api/terminal-api.ts @@ -60,6 +60,10 @@ export interface TerminalAPI { rows?: number ) => Promise>; checkTerminalPtyAlive: (terminalId: string) => Promise>; + updateTerminalDisplayOrders: ( + projectPath: string, + orders: Array<{ terminalId: string; displayOrder: number }> + ) => Promise; // Terminal Worktree Operations (isolated development) createTerminalWorktree: (request: CreateTerminalWorktreeRequest) => Promise; @@ -172,6 +176,12 @@ export const createTerminalAPI = (): TerminalAPI => ({ checkTerminalPtyAlive: (terminalId: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CHECK_PTY_ALIVE, terminalId), + updateTerminalDisplayOrders: ( + projectPath: string, + orders: Array<{ terminalId: string; displayOrder: number }> + ): Promise => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_UPDATE_DISPLAY_ORDERS, projectPath, orders), + // Terminal Worktree Operations (isolated development) createTerminalWorktree: (request: CreateTerminalWorktreeRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WORKTREE_CREATE, request), diff --git a/apps/frontend/src/renderer/components/SortableTerminalWrapper.tsx b/apps/frontend/src/renderer/components/SortableTerminalWrapper.tsx index ad6f421d..ea5873c2 100644 --- a/apps/frontend/src/renderer/components/SortableTerminalWrapper.tsx +++ b/apps/frontend/src/renderer/components/SortableTerminalWrapper.tsx @@ -1,10 +1,19 @@ -import React from 'react'; +import React, { useRef, forwardRef, useImperativeHandle } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import type { Task } from '../../shared/types'; -import { Terminal } from './Terminal'; +import { Terminal, type TerminalHandle } from './Terminal'; import { cn } from '../lib/utils'; +/** + * Handle interface exposed by SortableTerminalWrapper for external control. + * Allows parent components to trigger terminal operations like fit. + */ +export interface SortableTerminalWrapperHandle { + /** Refit the terminal to its container size */ + fit: () => void; +} + interface SortableTerminalWrapperProps { id: string; cwd?: string; @@ -19,65 +28,76 @@ interface SortableTerminalWrapperProps { onToggleExpand?: () => void; } -export function SortableTerminalWrapper({ - id, - cwd, - projectPath, - isActive, - onClose, - onActivate, - tasks, - onNewTaskClick, - terminalCount, - isExpanded, - onToggleExpand, -}: SortableTerminalWrapperProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ +export const SortableTerminalWrapper = forwardRef( + function SortableTerminalWrapper({ id, - data: { - type: 'terminal-panel', - terminalId: id, - }, - }); + cwd, + projectPath, + isActive, + onClose, + onActivate, + tasks, + onNewTaskClick, + terminalCount, + isExpanded, + onToggleExpand, + }, ref) { + const terminalRef = useRef(null); - const style = { - transform: CSS.Transform.toString(transform), - transition, - zIndex: isDragging ? 50 : undefined, - }; + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id, + data: { + type: 'terminal-panel', + terminalId: id, + }, + }); - return ( -
- -
- ); -} + // Expose fit method to parent components via ref + // This allows external triggering of terminal resize (e.g., after drag-drop reorder) + useImperativeHandle(ref, () => ({ + fit: () => terminalRef.current?.fit(), + }), []); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 50 : undefined, + }; + + return ( +
+ +
+ ); + } +); diff --git a/apps/frontend/src/renderer/components/Terminal.tsx b/apps/frontend/src/renderer/components/Terminal.tsx index 570266a7..03397680 100644 --- a/apps/frontend/src/renderer/components/Terminal.tsx +++ b/apps/frontend/src/renderer/components/Terminal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback, useState, useMemo } from 'react'; +import { useEffect, useRef, useCallback, useState, useMemo, forwardRef, useImperativeHandle } from 'react'; import { useDroppable, useDndContext } from '@dnd-kit/core'; import '@xterm/xterm/css/xterm.css'; import { FileDown } from 'lucide-react'; @@ -8,6 +8,7 @@ import { useSettingsStore } from '../stores/settings-store'; import { useToast } from '../hooks/use-toast'; import type { TerminalProps } from './terminal/types'; import type { TerminalWorktreeConfig } from '../../shared/types'; +import { TERMINAL_DOM_UPDATE_DELAY_MS } from '../../shared/constants'; import { TerminalHeader } from './terminal/TerminalHeader'; import { CreateWorktreeDialog } from './terminal/CreateWorktreeDialog'; import { useXterm } from './terminal/useXterm'; @@ -20,7 +21,17 @@ import { useTerminalFileDrop } from './terminal/useTerminalFileDrop'; const MIN_COLS = 10; const MIN_ROWS = 3; -export function Terminal({ +/** + * Handle interface exposed by Terminal component for external control. + * Used by parent components (e.g., SortableTerminalWrapper) to trigger operations + * like refitting the terminal after container size changes. + */ +export interface TerminalHandle { + /** Refit the terminal to its container size */ + fit: () => void; +} + +export const Terminal = forwardRef(function Terminal({ id, cwd, projectPath, @@ -34,7 +45,7 @@ export function Terminal({ isDragging, isExpanded, onToggleExpand, -}: TerminalProps) { +}, ref) { const isMountedRef = useRef(true); const isCreatedRef = useRef(false); // Track deliberate terminal recreation (e.g., worktree switching) @@ -103,6 +114,7 @@ export function Terminal({ const { terminalRef, xtermRef: _xtermRef, + fit, write: _write, // Output now handled by useGlobalTerminalListeners writeln, focus, @@ -120,6 +132,12 @@ export function Terminal({ onDimensionsReady: handleDimensionsReady, }); + // Expose fit method to parent components via ref + // This allows external triggering of terminal resize (e.g., after drag-drop reorder) + useImperativeHandle(ref, () => ({ + fit, + }), [fit]); + // Use ready dimensions for PTY creation (wait until xterm has measured) // This prevents creating PTY with default 80x24 when container is smaller const ptyDimensions = useMemo(() => { @@ -171,6 +189,14 @@ export function Terminal({ } }, [isActive, focus]); + // Refit terminal when expansion state changes + useEffect(() => { + const timeoutId = setTimeout(() => { + fit(); + }, TERMINAL_DOM_UPDATE_DELAY_MS); + return () => clearTimeout(timeoutId); + }, [isExpanded, fit]); + // Trigger deferred Claude resume when terminal becomes active // This ensures Claude sessions are only resumed when the user actually views the terminal, // preventing all terminals from resuming simultaneously on app startup (which can crash the app) @@ -426,4 +452,4 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title )} ); -} +}); diff --git a/apps/frontend/src/renderer/components/TerminalGrid.tsx b/apps/frontend/src/renderer/components/TerminalGrid.tsx index 2048b886..bc2af805 100644 --- a/apps/frontend/src/renderer/components/TerminalGrid.tsx +++ b/apps/frontend/src/renderer/components/TerminalGrid.tsx @@ -35,6 +35,7 @@ import { cn } from '../lib/utils'; import { useTerminalStore } from '../stores/terminal-store'; import { useTaskStore } from '../stores/task-store'; import { useFileExplorerStore } from '../stores/file-explorer-store'; +import { TERMINAL_DOM_UPDATE_DELAY_MS } from '../../shared/constants'; import type { SessionDateInfo } from '../../shared/types'; interface TerminalGridProps { @@ -148,11 +149,17 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }: if (result.success && result.data) { console.warn(`[TerminalGrid] Main process restored ${result.data.restored} sessions from ${date}`); + // Sort sessions by displayOrder before restoring to preserve user's tab ordering + const sortedSessions = [...sessionsToRestore].sort((a, b) => { + const orderA = a.displayOrder ?? Number.MAX_SAFE_INTEGER; + const orderB = b.displayOrder ?? Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + // Add each successfully restored session to the renderer's terminal store for (const sessionResult of result.data.sessions) { if (sessionResult.success) { - // Find the full session data - const fullSession = sessionsToRestore.find(s => s.id === sessionResult.id); + const fullSession = sortedSessions.find(s => s.id === sessionResult.id); if (fullSession) { console.warn(`[TerminalGrid] Adding restored terminal to store: ${fullSession.id}`); addRestoredTerminal(fullSession); @@ -292,6 +299,29 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }: if (activeId !== overId && terminals.some(t => t.id === overId)) { reorderTerminals(activeId, overId); + + // Persist the new order to disk so it survives app restarts + // Use a microtask to ensure the store has updated before we read the new order + if (projectPath) { + queueMicrotask(async () => { + const updatedTerminals = useTerminalStore.getState().terminals; + const orders = updatedTerminals + .filter(t => t.projectPath === projectPath || !t.projectPath) + .map(t => ({ terminalId: t.id, displayOrder: t.displayOrder ?? 0 })); + try { + const result = await window.electronAPI.updateTerminalDisplayOrders(projectPath, orders); + if (!result.success) { + console.warn('[TerminalGrid] Failed to persist terminal order:', result.error); + } + } catch (error) { + console.warn('[TerminalGrid] Failed to persist terminal order:', error); + } + }); + } + + setTimeout(() => { + window.dispatchEvent(new CustomEvent('terminal-refit-all')); + }, TERMINAL_DOM_UPDATE_DELAY_MS); } return; } diff --git a/apps/frontend/src/renderer/components/terminal/useXterm.ts b/apps/frontend/src/renderer/components/terminal/useXterm.ts index 001b0fa4..856a32d0 100644 --- a/apps/frontend/src/renderer/components/terminal/useXterm.ts +++ b/apps/frontend/src/renderer/components/terminal/useXterm.ts @@ -82,7 +82,11 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea }); const fitAddon = new FitAddon(); - const webLinksAddon = new WebLinksAddon(); + const webLinksAddon = new WebLinksAddon((_event, uri) => { + window.electronAPI?.openExternal?.(uri).catch((error) => { + console.warn('[useXterm] Failed to open URL:', uri, error); + }); + }); const serializeAddon = new SerializeAddon(); xterm.loadAddon(fitAddon); @@ -341,6 +345,24 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea } }, [onDimensionsReady]); + // Listen for terminal refit events (triggered after drag-drop reorder) + useEffect(() => { + const handleRefitAll = () => { + if (fitAddonRef.current && xtermRef.current && terminalRef.current) { + const rect = terminalRef.current.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + fitAddonRef.current.fit(); + const cols = xtermRef.current.cols; + const rows = xtermRef.current.rows; + setDimensions({ cols, rows }); + } + } + }; + + window.addEventListener('terminal-refit-all', handleRefitAll); + return () => window.removeEventListener('terminal-refit-all', handleRefitAll); + }, []); + const fit = useCallback(() => { if (fitAddonRef.current && xtermRef.current) { fitAddonRef.current.fit(); diff --git a/apps/frontend/src/renderer/lib/mocks/terminal-mock.ts b/apps/frontend/src/renderer/lib/mocks/terminal-mock.ts index 6c927a6d..a74f6471 100644 --- a/apps/frontend/src/renderer/lib/mocks/terminal-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/terminal-mock.ts @@ -88,6 +88,10 @@ export const terminalMock = { data: { alive: false } }), + updateTerminalDisplayOrders: async () => ({ + success: true + }), + // Terminal Event Listeners (no-op in browser) onTerminalOutput: () => () => {}, onTerminalExit: () => () => {}, diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts index 7c671913..b943b753 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -93,6 +93,7 @@ export interface Terminal { worktreeConfig?: TerminalWorktreeConfig; // Associated worktree for isolated development isClaudeBusy?: boolean; // Whether Claude Code is actively processing (for visual indicator) pendingClaudeResume?: boolean; // Whether this terminal has a pending Claude resume (deferred until tab activated) + displayOrder?: number; // Display order for tab persistence (lower = further left) } interface TerminalLayout { @@ -159,6 +160,7 @@ export const useTerminalStore = create((set, get) => ({ isClaudeMode: false, // outputBuffer removed - managed by terminalBufferManager projectPath, + displayOrder: state.terminals.length, // New terminals appear at the end }; set((state) => ({ @@ -193,6 +195,8 @@ export const useTerminalStore = create((set, get) => ({ projectPath: session.projectPath, // Worktree config is validated in main process before restore worktreeConfig: session.worktreeConfig, + // Restore displayOrder for tab position persistence (falls back to end if not set) + displayOrder: session.displayOrder ?? state.terminals.length, }; // Restore buffer to buffer manager @@ -234,6 +238,7 @@ export const useTerminalStore = create((set, get) => ({ createdAt: new Date(), isClaudeMode: false, projectPath, + displayOrder: state.terminals.length, // New terminals appear at the end }; set((state) => ({ @@ -355,8 +360,15 @@ export const useTerminalStore = create((set, get) => ({ return state; } + // Reorder terminals and update displayOrder values based on new positions + const reorderedTerminals = arrayMove(state.terminals, oldIndex, newIndex); + const terminalsWithOrder = reorderedTerminals.map((terminal, index) => ({ + ...terminal, + displayOrder: index, + })); + return { - terminals: arrayMove(state.terminals, oldIndex, newIndex), + terminals: terminalsWithOrder, }; }); }, @@ -460,8 +472,16 @@ export async function restoreTerminalSessions(projectPath: string): Promise { + const orderA = a.displayOrder ?? Number.MAX_SAFE_INTEGER; + const orderB = b.displayOrder ?? Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + + // Add terminals to the store in correct order (they'll be created in the TerminalGrid component) + for (const session of sortedSessions) { store.addRestoredTerminal(session); } diff --git a/apps/frontend/src/shared/constants/config.ts b/apps/frontend/src/shared/constants/config.ts index daa0954e..ab915a16 100644 --- a/apps/frontend/src/shared/constants/config.ts +++ b/apps/frontend/src/shared/constants/config.ts @@ -3,6 +3,13 @@ * Default settings, file paths, and project structure */ +// ============================================ +// Terminal Timing Constants +// ============================================ + +/** Delay for DOM updates before terminal operations (refit, resize) */ +export const TERMINAL_DOM_UPDATE_DELAY_MS = 50; + // ============================================ // UI Scale Constants // ============================================ diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 7bed40cb..c8dea9ce 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -78,6 +78,7 @@ export const IPC_CHANNELS = { TERMINAL_GET_SESSIONS_FOR_DATE: 'terminal:getSessionsForDate', TERMINAL_RESTORE_FROM_DATE: 'terminal:restoreFromDate', TERMINAL_CHECK_PTY_ALIVE: 'terminal:checkPtyAlive', + TERMINAL_UPDATE_DISPLAY_ORDERS: 'terminal:updateDisplayOrders', // Persist terminal display order after drag-drop reorder // Terminal worktree operations (isolated development in worktrees) TERMINAL_WORKTREE_CREATE: 'terminal:worktreeCreate', diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 3ed5c339..f6363800 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -211,6 +211,10 @@ export interface ElectronAPI { restoreTerminalSessionsFromDate: (date: string, projectPath: string, cols?: number, rows?: number) => Promise>; saveTerminalBuffer: (terminalId: string, serialized: string) => Promise; checkTerminalPtyAlive: (terminalId: string) => Promise>; + updateTerminalDisplayOrders: ( + projectPath: string, + orders: Array<{ terminalId: string; displayOrder: number }> + ) => Promise; // Terminal worktree operations (isolated development) createTerminalWorktree: (request: CreateTerminalWorktreeRequest) => Promise; diff --git a/apps/frontend/src/shared/types/terminal.ts b/apps/frontend/src/shared/types/terminal.ts index 2d91b71e..4c719605 100644 --- a/apps/frontend/src/shared/types/terminal.ts +++ b/apps/frontend/src/shared/types/terminal.ts @@ -37,6 +37,8 @@ export interface TerminalSession { outputBuffer: string; createdAt: string; lastActiveAt: string; + /** Display order for tab persistence (lower = further left) */ + displayOrder?: number; /** Associated worktree configuration (validated on restore) */ worktreeConfig?: TerminalWorktreeConfig; }