-
-
Live Agents
+ {/* URL Section */}
+
+
+ Web Interface URL
+
+
+
+ {webInterfaceUrl.replace(/^https?:\/\//, '')}
+
+
+
+
+ {/* Open in Browser Button */}
+
-
- {sessions.filter(s => s.tunnelActive).map(session => {
- const group = groups.find(g => g.id === session.groupId);
- return (
-
-
-
- {group && (
-
- {group.name}
-
- )}
-
-
-
- {session.tunnelUrl && (
-
- )}
-
-
- );
- })}
+
+ {/* QR Code Section */}
+
+
+ Scan with Mobile
+
+
+
+
+
+
+ {/* Turn Off Button */}
+
+
diff --git a/src/renderer/hooks/useSessionManager.ts b/src/renderer/hooks/useSessionManager.ts
index 4f231baf0..e3478e8df 100644
--- a/src/renderer/hooks/useSessionManager.ts
+++ b/src/renderer/hooks/useSessionManager.ts
@@ -18,7 +18,7 @@ export interface UseSessionManagerReturn {
createNewSession: (agentId: string, workingDir: string, name: string) => void;
deleteSession: (id: string, showConfirmation: (message: string, onConfirm: () => void) => void) => void;
toggleInputMode: () => void;
- toggleTunnel: (sessId: string, tunnelProvider: string) => void;
+ toggleLive: (sessId: string) => void;
updateScratchPad: (content: string) => void;
updateScratchPadState: (state: {
mode: 'edit' | 'preview';
@@ -237,7 +237,7 @@ export function useSessionManager(): UseSessionManagerReturn {
aiPid: aiSpawnResult.pid,
terminalPid: terminalSpawnResult.pid,
port: 3000 + Math.floor(Math.random() * 100),
- tunnelActive: false,
+ isLive: false,
changedFiles: [],
fileTree: [],
fileExplorerExpanded: [],
@@ -289,14 +289,15 @@ export function useSessionManager(): UseSessionManagerReturn {
}));
};
- const toggleTunnel = (sessId: string, tunnelProvider: string) => {
+ const toggleLive = (sessId: string) => {
+ // Live toggle is handled in App.tsx via IPC
+ // This is just a stub for the interface
setSessions(prev => prev.map(s => {
if (s.id !== sessId) return s;
- const isActive = !s.tunnelActive;
return {
...s,
- tunnelActive: isActive,
- tunnelUrl: isActive ? `https://${generateId()}.${tunnelProvider === 'ngrok' ? 'ngrok.io' : 'trycloudflare.com'}` : undefined
+ isLive: !s.isLive,
+ liveUrl: undefined
};
}));
};
@@ -405,7 +406,7 @@ export function useSessionManager(): UseSessionManagerReturn {
createNewSession,
deleteSession,
toggleInputMode,
- toggleTunnel,
+ toggleLive,
updateScratchPad,
updateScratchPadState,
startRenamingSession,
diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts
index c6addc381..985ef509c 100644
--- a/src/renderer/types/index.ts
+++ b/src/renderer/types/index.ts
@@ -1,34 +1,16 @@
// Type definitions for Maestro renderer
+// Re-export theme types from shared location
+export { Theme, ThemeId, ThemeMode, ThemeColors, isValidThemeId } from '../../shared/theme-types';
+
export type ToolType = 'claude' | 'aider' | 'opencode' | 'terminal';
export type SessionState = 'idle' | 'busy' | 'waiting_input' | 'connecting' | 'error';
export type FileChangeType = 'modified' | 'added' | 'deleted';
export type RightPanelTab = 'files' | 'history' | 'scratchpad';
export type ScratchPadMode = 'raw' | 'preview' | 'wysiwyg';
-export type ThemeId = 'dracula' | 'monokai' | 'github-light' | 'solarized-light' | 'nord' | 'tokyo-night' | 'one-light' | 'gruvbox-light' | 'catppuccin-mocha' | 'gruvbox-dark' | 'catppuccin-latte' | 'ayu-light' | 'pedurple' | 'maestros-choice' | 'dre-synth' | 'inquest';
export type FocusArea = 'sidebar' | 'main' | 'right';
export type LLMProvider = 'openrouter' | 'anthropic' | 'ollama';
-export interface Theme {
- id: ThemeId;
- name: string;
- mode: 'light' | 'dark' | 'vibe';
- colors: {
- bgMain: string;
- bgSidebar: string;
- bgActivity: string;
- border: string;
- textMain: string;
- textDim: string;
- accent: string;
- accentDim: string;
- accentText: string;
- success: string;
- warning: string;
- error: string;
- };
-}
-
export interface Shortcut {
id: string;
label: string;
@@ -140,10 +122,9 @@ export interface Session {
aiPid: number;
terminalPid: number;
port: number;
- tunnelActive: boolean;
- tunnelUrl?: string;
- tunnelPort?: number;
- tunnelUuid?: string;
+ // Live mode - makes session accessible via web interface
+ isLive: boolean;
+ liveUrl?: string;
changedFiles: FileArtifact[];
isGitRepo: boolean;
// File Explorer per-session state
diff --git a/src/shared/index.ts b/src/shared/index.ts
new file mode 100644
index 000000000..064eb2931
--- /dev/null
+++ b/src/shared/index.ts
@@ -0,0 +1,10 @@
+/**
+ * Shared types and utilities for Maestro
+ *
+ * This module exports types that are used across multiple parts of the application:
+ * - Main process (Electron)
+ * - Renderer process (Desktop React app)
+ * - Web interface (Mobile and Desktop web builds)
+ */
+
+export * from './theme-types';
diff --git a/src/shared/theme-types.ts b/src/shared/theme-types.ts
new file mode 100644
index 000000000..1bc7ba715
--- /dev/null
+++ b/src/shared/theme-types.ts
@@ -0,0 +1,106 @@
+/**
+ * Shared theme type definitions for Maestro
+ *
+ * This file contains theme types used across:
+ * - Main process (Electron)
+ * - Renderer process (Desktop React app)
+ * - Web interface (Mobile and Desktop web builds)
+ *
+ * Keep this file dependency-free to ensure it can be imported anywhere.
+ */
+
+/**
+ * Available theme identifiers
+ */
+export type ThemeId =
+ | 'dracula'
+ | 'monokai'
+ | 'github-light'
+ | 'solarized-light'
+ | 'nord'
+ | 'tokyo-night'
+ | 'one-light'
+ | 'gruvbox-light'
+ | 'catppuccin-mocha'
+ | 'gruvbox-dark'
+ | 'catppuccin-latte'
+ | 'ayu-light'
+ | 'pedurple'
+ | 'maestros-choice'
+ | 'dre-synth'
+ | 'inquest';
+
+/**
+ * Theme mode indicating the overall brightness/style
+ */
+export type ThemeMode = 'light' | 'dark' | 'vibe';
+
+/**
+ * Color palette for a theme
+ * Each color serves a specific purpose in the UI
+ */
+export interface ThemeColors {
+ /** Main background color for primary content areas */
+ bgMain: string;
+ /** Sidebar background color */
+ bgSidebar: string;
+ /** Background for interactive/activity elements */
+ bgActivity: string;
+ /** Border color for dividers and outlines */
+ border: string;
+ /** Primary text color */
+ textMain: string;
+ /** Dimmed/secondary text color */
+ textDim: string;
+ /** Accent color for highlights and interactive elements */
+ accent: string;
+ /** Dimmed accent (typically with alpha transparency) */
+ accentDim: string;
+ /** Text color for accent contexts */
+ accentText: string;
+ /** Success state color (green tones) */
+ success: string;
+ /** Warning state color (yellow/orange tones) */
+ warning: string;
+ /** Error state color (red tones) */
+ error: string;
+}
+
+/**
+ * Complete theme definition
+ */
+export interface Theme {
+ /** Unique identifier for the theme */
+ id: ThemeId;
+ /** Human-readable display name */
+ name: string;
+ /** Theme mode (light, dark, or vibe) */
+ mode: ThemeMode;
+ /** Color palette */
+ colors: ThemeColors;
+}
+
+/**
+ * Type guard to check if a string is a valid ThemeId
+ */
+export function isValidThemeId(id: string): id is ThemeId {
+ const validIds: ThemeId[] = [
+ 'dracula',
+ 'monokai',
+ 'github-light',
+ 'solarized-light',
+ 'nord',
+ 'tokyo-night',
+ 'one-light',
+ 'gruvbox-light',
+ 'catppuccin-mocha',
+ 'gruvbox-dark',
+ 'catppuccin-latte',
+ 'ayu-light',
+ 'pedurple',
+ 'maestros-choice',
+ 'dre-synth',
+ 'inquest',
+ ];
+ return validIds.includes(id as ThemeId);
+}
diff --git a/src/web/components/Badge.tsx b/src/web/components/Badge.tsx
new file mode 100644
index 000000000..7640086bd
--- /dev/null
+++ b/src/web/components/Badge.tsx
@@ -0,0 +1,299 @@
+/**
+ * Badge component for Maestro web interface
+ *
+ * A reusable badge/status indicator component that supports multiple variants
+ * and sizes. Ideal for showing session states, labels, and status information.
+ * Uses theme colors via CSS custom properties for consistent styling.
+ */
+
+import React, { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
+import { useTheme } from './ThemeProvider';
+
+/**
+ * Badge variant types
+ * - default: Neutral badge using subtle colors
+ * - success: Positive state (green) - Ready/idle sessions
+ * - warning: Warning state (yellow) - Agent thinking/busy
+ * - error: Error state (red) - No connection/error
+ * - info: Informational (accent color)
+ * - connecting: Orange pulsing state for connecting sessions
+ */
+export type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'connecting';
+
+/**
+ * Badge size options
+ */
+export type BadgeSize = 'sm' | 'md' | 'lg';
+
+/**
+ * Badge style options
+ * - solid: Filled background with contrasting text
+ * - outline: Transparent background with colored border
+ * - subtle: Soft colored background with matching text
+ * - dot: Minimal dot indicator (no text shown)
+ */
+export type BadgeStyle = 'solid' | 'outline' | 'subtle' | 'dot';
+
+export interface BadgeProps extends HTMLAttributes
{
+ /** Visual variant of the badge */
+ variant?: BadgeVariant;
+ /** Size of the badge */
+ size?: BadgeSize;
+ /** Visual style of the badge */
+ badgeStyle?: BadgeStyle;
+ /** Optional icon to display before the text */
+ icon?: ReactNode;
+ /** Whether to show a pulsing animation (useful for "connecting" states) */
+ pulse?: boolean;
+ /** Children content (text or elements) */
+ children?: ReactNode;
+}
+
+/**
+ * Size-based style configurations
+ */
+const sizeStyles: Record = {
+ sm: {
+ className: 'px-1.5 py-0.5 text-xs gap-1',
+ borderRadius: '4px',
+ dotSize: '6px',
+ },
+ md: {
+ className: 'px-2 py-0.5 text-sm gap-1.5',
+ borderRadius: '6px',
+ dotSize: '8px',
+ },
+ lg: {
+ className: 'px-2.5 py-1 text-base gap-2',
+ borderRadius: '8px',
+ dotSize: '10px',
+ },
+};
+
+/**
+ * Badge component for the Maestro web interface
+ *
+ * @example
+ * ```tsx
+ * // Status badges
+ * Ready
+ * Processing
+ * Disconnected
+ *
+ * // Connecting state with pulse
+ * Connecting
+ *
+ * // Dot-only indicator
+ *
+ *
+ * // Outline style
+ * AI Mode
+ *
+ * // With icon
+ * }>
+ * Complete
+ *
+ * ```
+ */
+export const Badge = forwardRef(function Badge(
+ {
+ variant = 'default',
+ size = 'md',
+ badgeStyle = 'subtle',
+ icon,
+ pulse = false,
+ children,
+ className = '',
+ style,
+ ...props
+ },
+ ref
+) {
+ const { theme } = useTheme();
+ const colors = theme.colors;
+
+ const sizeConfig = sizeStyles[size];
+ const shouldPulse = pulse || variant === 'connecting';
+
+ /**
+ * Get the primary color for the variant
+ */
+ const getVariantColor = (): string => {
+ switch (variant) {
+ case 'success':
+ return colors.success;
+ case 'warning':
+ return colors.warning;
+ case 'error':
+ return colors.error;
+ case 'info':
+ return colors.accent;
+ case 'connecting':
+ // Orange color for connecting state
+ return '#f97316';
+ case 'default':
+ default:
+ return colors.textDim;
+ }
+ };
+
+ /**
+ * Get variant-specific styles based on badgeStyle
+ */
+ const getStyles = (): React.CSSProperties => {
+ const primaryColor = getVariantColor();
+
+ switch (badgeStyle) {
+ case 'solid':
+ return {
+ backgroundColor: primaryColor,
+ color: '#ffffff',
+ border: 'none',
+ };
+ case 'outline':
+ return {
+ backgroundColor: 'transparent',
+ color: primaryColor,
+ border: `1px solid ${primaryColor}`,
+ };
+ case 'subtle':
+ return {
+ backgroundColor: `${primaryColor}20`, // 20 = ~12% opacity in hex
+ color: primaryColor,
+ border: 'none',
+ };
+ case 'dot':
+ return {
+ backgroundColor: primaryColor,
+ border: 'none',
+ };
+ default:
+ return {};
+ }
+ };
+
+ // Render dot-only badge
+ if (badgeStyle === 'dot') {
+ return (
+
+ );
+ }
+
+ const combinedStyles: React.CSSProperties = {
+ ...getStyles(),
+ borderRadius: sizeConfig.borderRadius,
+ display: 'inline-flex',
+ alignItems: 'center',
+ fontWeight: 500,
+ whiteSpace: 'nowrap',
+ lineHeight: 1,
+ ...style,
+ };
+
+ return (
+
+ {icon && {icon}}
+ {children && {children}}
+
+ );
+});
+
+/**
+ * StatusDot component - A simple circular status indicator
+ *
+ * Convenience component for dot-only badges commonly used in session lists.
+ *
+ * @example
+ * ```tsx
+ * // In a session list item
+ *
+ *
+ *
+ *
+ * ```
+ */
+export type SessionStatus = 'idle' | 'busy' | 'error' | 'connecting';
+
+export interface StatusDotProps extends Omit {
+ /** Session status to display */
+ status: SessionStatus;
+}
+
+/**
+ * Map session status to badge variant
+ */
+const statusToVariant: Record = {
+ idle: 'success',
+ busy: 'warning',
+ error: 'error',
+ connecting: 'connecting',
+};
+
+export const StatusDot = forwardRef(function StatusDot(
+ { status, size = 'sm', ...props },
+ ref
+) {
+ return (
+
+ );
+});
+
+/**
+ * ModeBadge component - Shows AI or Terminal mode indicator
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ * ```
+ */
+export type InputMode = 'ai' | 'terminal';
+
+export interface ModeBadgeProps extends Omit {
+ /** Current input mode */
+ mode: InputMode;
+}
+
+export const ModeBadge = forwardRef(function ModeBadge(
+ { mode, size = 'sm', badgeStyle = 'outline', ...props },
+ ref
+) {
+ return (
+
+ {mode === 'ai' ? 'AI' : 'Terminal'}
+
+ );
+});
+
+export default Badge;
diff --git a/src/web/components/Button.tsx b/src/web/components/Button.tsx
new file mode 100644
index 000000000..cb0dc986b
--- /dev/null
+++ b/src/web/components/Button.tsx
@@ -0,0 +1,296 @@
+/**
+ * Button component for Maestro web interface
+ *
+ * A reusable button component that supports multiple variants, sizes, and states.
+ * Uses theme colors via CSS custom properties for consistent styling.
+ */
+
+import React, { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
+import { useTheme } from './ThemeProvider';
+
+/**
+ * Button variant types
+ * - primary: Main call-to-action, uses accent color
+ * - secondary: Secondary action, uses subtle background
+ * - ghost: No background, hover reveals background
+ * - danger: Destructive action, uses error color
+ * - success: Positive action, uses success color
+ */
+export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
+
+/**
+ * Button size options
+ */
+export type ButtonSize = 'sm' | 'md' | 'lg';
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ /** Visual variant of the button */
+ variant?: ButtonVariant;
+ /** Size of the button */
+ size?: ButtonSize;
+ /** Whether the button is in a loading state */
+ loading?: boolean;
+ /** Icon to display before the text */
+ leftIcon?: ReactNode;
+ /** Icon to display after the text */
+ rightIcon?: ReactNode;
+ /** Whether the button should take full width */
+ fullWidth?: boolean;
+ /** Children content */
+ children?: ReactNode;
+}
+
+/**
+ * Size-based style configurations
+ */
+const sizeStyles: Record = {
+ sm: {
+ className: 'px-2 py-1 text-xs gap-1',
+ borderRadius: '4px',
+ },
+ md: {
+ className: 'px-3 py-1.5 text-sm gap-1.5',
+ borderRadius: '6px',
+ },
+ lg: {
+ className: 'px-4 py-2 text-base gap-2',
+ borderRadius: '8px',
+ },
+};
+
+/**
+ * Loading spinner component
+ */
+function LoadingSpinner({ size }: { size: ButtonSize }) {
+ const spinnerSize = size === 'sm' ? 12 : size === 'md' ? 14 : 16;
+ return (
+
+ );
+}
+
+/**
+ * Button component for the Maestro web interface
+ *
+ * @example
+ * ```tsx
+ * // Primary button
+ *
+ *
+ * // Button with loading state
+ *
+ *
+ * // Button with icons
+ * }>
+ * Add Item
+ *
+ *
+ * // Danger button
+ *
+ * ```
+ */
+export const Button = forwardRef(function Button(
+ {
+ variant = 'primary',
+ size = 'md',
+ loading = false,
+ leftIcon,
+ rightIcon,
+ fullWidth = false,
+ disabled,
+ children,
+ className = '',
+ style,
+ ...props
+ },
+ ref
+) {
+ const { theme } = useTheme();
+ const colors = theme.colors;
+
+ const isDisabled = disabled || loading;
+
+ /**
+ * Get variant-specific styles
+ */
+ const getVariantStyles = (): React.CSSProperties => {
+ const baseTransition = 'background-color 150ms ease, border-color 150ms ease, opacity 150ms ease';
+
+ switch (variant) {
+ case 'primary':
+ return {
+ backgroundColor: colors.accent,
+ color: '#ffffff',
+ border: 'none',
+ transition: baseTransition,
+ };
+ case 'secondary':
+ return {
+ backgroundColor: colors.bgActivity,
+ color: colors.textMain,
+ border: `1px solid ${colors.border}`,
+ transition: baseTransition,
+ };
+ case 'ghost':
+ return {
+ backgroundColor: 'transparent',
+ color: colors.textMain,
+ border: '1px solid transparent',
+ transition: baseTransition,
+ };
+ case 'danger':
+ return {
+ backgroundColor: colors.error,
+ color: '#ffffff',
+ border: 'none',
+ transition: baseTransition,
+ };
+ case 'success':
+ return {
+ backgroundColor: colors.success,
+ color: '#ffffff',
+ border: 'none',
+ transition: baseTransition,
+ };
+ default:
+ return {};
+ }
+ };
+
+ /**
+ * Get disabled styles
+ */
+ const getDisabledStyles = (): React.CSSProperties => {
+ if (!isDisabled) return {};
+ return {
+ opacity: 0.5,
+ cursor: 'not-allowed',
+ };
+ };
+
+ const sizeConfig = sizeStyles[size];
+ const variantStyles = getVariantStyles();
+ const disabledStyles = getDisabledStyles();
+
+ const combinedStyles: React.CSSProperties = {
+ ...variantStyles,
+ ...disabledStyles,
+ borderRadius: sizeConfig.borderRadius,
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontWeight: 500,
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
+ outline: 'none',
+ userSelect: 'none',
+ width: fullWidth ? '100%' : undefined,
+ ...style,
+ };
+
+ // Construct class names
+ const classNames = [
+ sizeConfig.className,
+ 'font-medium whitespace-nowrap',
+ 'focus:ring-2 focus:ring-offset-1',
+ 'transition-colors',
+ fullWidth ? 'w-full' : '',
+ className,
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ return (
+
+ );
+});
+
+/**
+ * IconButton component for icon-only buttons
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ * ```
+ */
+export interface IconButtonProps extends Omit {
+ /** Accessible label for the button */
+ 'aria-label': string;
+}
+
+export const IconButton = forwardRef(function IconButton(
+ { size = 'md', className = '', style, children, ...props },
+ ref
+) {
+ // Square padding for icon buttons
+ const iconSizeStyles: Record = {
+ sm: { padding: '4px', minSize: '24px' },
+ md: { padding: '6px', minSize: '32px' },
+ lg: { padding: '8px', minSize: '40px' },
+ };
+
+ const sizeConfig = iconSizeStyles[size];
+
+ return (
+
+ );
+});
+
+export default Button;
diff --git a/src/web/components/Card.tsx b/src/web/components/Card.tsx
new file mode 100644
index 000000000..e5196df12
--- /dev/null
+++ b/src/web/components/Card.tsx
@@ -0,0 +1,522 @@
+/**
+ * Card component for Maestro web interface
+ *
+ * A reusable card container component that supports multiple variants, padding options,
+ * and interactive states. Ideal for session cards, information panels, and grouped content.
+ * Uses theme colors via CSS custom properties for consistent styling.
+ */
+
+import React, { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
+import { useTheme } from './ThemeProvider';
+
+/**
+ * Card variant types
+ * - default: Standard card with subtle background
+ * - elevated: Card with shadow for emphasis
+ * - outlined: Card with border, transparent background
+ * - filled: Card with solid activity background
+ * - ghost: Minimal card, only visible on hover
+ */
+export type CardVariant = 'default' | 'elevated' | 'outlined' | 'filled' | 'ghost';
+
+/**
+ * Card padding options
+ */
+export type CardPadding = 'none' | 'sm' | 'md' | 'lg';
+
+/**
+ * Card border radius options
+ */
+export type CardRadius = 'none' | 'sm' | 'md' | 'lg' | 'full';
+
+export interface CardProps extends HTMLAttributes {
+ /** Visual variant of the card */
+ variant?: CardVariant;
+ /** Padding inside the card */
+ padding?: CardPadding;
+ /** Border radius of the card */
+ radius?: CardRadius;
+ /** Whether the card is interactive (clickable) */
+ interactive?: boolean;
+ /** Whether the card is in a selected/active state */
+ selected?: boolean;
+ /** Whether the card is disabled */
+ disabled?: boolean;
+ /** Whether the card should take full width */
+ fullWidth?: boolean;
+ /** Children content */
+ children?: ReactNode;
+}
+
+/**
+ * Padding style configurations
+ */
+const paddingStyles: Record = {
+ none: '',
+ sm: 'p-2',
+ md: 'p-3',
+ lg: 'p-4',
+};
+
+/**
+ * Border radius style configurations
+ */
+const radiusStyles: Record = {
+ none: '0',
+ sm: '4px',
+ md: '8px',
+ lg: '12px',
+ full: '9999px',
+};
+
+/**
+ * Card component for the Maestro web interface
+ *
+ * @example
+ * ```tsx
+ * // Basic card
+ *
+ * Card content here
+ *
+ *
+ * // Interactive session card
+ *
+ *
+ *
+ *
+ * // Elevated card for emphasis
+ *
+ *
+ *
+ *
+ * // Card with custom padding and radius
+ *
+ *
+ *
+ * ```
+ */
+export const Card = forwardRef(function Card(
+ {
+ variant = 'default',
+ padding = 'md',
+ radius = 'md',
+ interactive = false,
+ selected = false,
+ disabled = false,
+ fullWidth = false,
+ children,
+ className = '',
+ style,
+ onClick,
+ ...props
+ },
+ ref
+) {
+ const { theme } = useTheme();
+ const colors = theme.colors;
+
+ /**
+ * Get variant-specific styles
+ */
+ const getVariantStyles = (): React.CSSProperties => {
+ const baseTransition = 'background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease';
+
+ switch (variant) {
+ case 'default':
+ return {
+ backgroundColor: colors.bgActivity,
+ color: colors.textMain,
+ border: 'none',
+ transition: baseTransition,
+ };
+ case 'elevated':
+ return {
+ backgroundColor: colors.bgActivity,
+ color: colors.textMain,
+ border: 'none',
+ boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
+ transition: baseTransition,
+ };
+ case 'outlined':
+ return {
+ backgroundColor: 'transparent',
+ color: colors.textMain,
+ border: `1px solid ${colors.border}`,
+ transition: baseTransition,
+ };
+ case 'filled':
+ return {
+ backgroundColor: colors.bgSidebar,
+ color: colors.textMain,
+ border: 'none',
+ transition: baseTransition,
+ };
+ case 'ghost':
+ return {
+ backgroundColor: 'transparent',
+ color: colors.textMain,
+ border: '1px solid transparent',
+ transition: baseTransition,
+ };
+ default:
+ return {};
+ }
+ };
+
+ /**
+ * Get interactive/hover styles
+ */
+ const getInteractiveStyles = (): React.CSSProperties => {
+ if (!interactive || disabled) return {};
+ return {
+ cursor: 'pointer',
+ };
+ };
+
+ /**
+ * Get selected state styles
+ */
+ const getSelectedStyles = (): React.CSSProperties => {
+ if (!selected) return {};
+ return {
+ borderColor: colors.accent,
+ backgroundColor: variant === 'outlined' ? colors.accentDim : colors.bgActivity,
+ boxShadow: `0 0 0 1px ${colors.accent}`,
+ };
+ };
+
+ /**
+ * Get disabled styles
+ */
+ const getDisabledStyles = (): React.CSSProperties => {
+ if (!disabled) return {};
+ return {
+ opacity: 0.5,
+ cursor: 'not-allowed',
+ pointerEvents: 'none',
+ };
+ };
+
+ const variantStyles = getVariantStyles();
+ const interactiveStyles = getInteractiveStyles();
+ const selectedStyles = getSelectedStyles();
+ const disabledStyles = getDisabledStyles();
+
+ const combinedStyles: React.CSSProperties = {
+ ...variantStyles,
+ ...interactiveStyles,
+ ...selectedStyles,
+ ...disabledStyles,
+ borderRadius: radiusStyles[radius],
+ width: fullWidth ? '100%' : undefined,
+ ...style,
+ };
+
+ // Construct class names
+ const classNames = [
+ paddingStyles[padding],
+ interactive && !disabled ? 'hover:brightness-110 active:scale-[0.99]' : '',
+ fullWidth ? 'w-full' : '',
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
+ className,
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ // Handle keyboard interaction for interactive cards
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (interactive && !disabled && (e.key === 'Enter' || e.key === ' ')) {
+ e.preventDefault();
+ onClick?.(e as unknown as React.MouseEvent);
+ }
+ props.onKeyDown?.(e);
+ };
+
+ return (
+
+ {children}
+
+ );
+});
+
+/**
+ * CardHeader component for consistent card headers
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ * Content
+ *
+ * ```
+ */
+export interface CardHeaderProps extends HTMLAttributes {
+ /** Main title text */
+ title?: ReactNode;
+ /** Subtitle or secondary text */
+ subtitle?: ReactNode;
+ /** Action element (button, icon, etc.) on the right side */
+ action?: ReactNode;
+}
+
+export const CardHeader = forwardRef(function CardHeader(
+ { title, subtitle, action, className = '', style, children, ...props },
+ ref
+) {
+ const { theme } = useTheme();
+ const colors = theme.colors;
+
+ // If children are provided, render them directly
+ if (children) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {action &&
{action}
}
+
+ );
+});
+
+/**
+ * CardBody component for main card content
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ * Main content goes here
+ *
+ *
+ * ```
+ */
+export interface CardBodyProps extends HTMLAttributes {
+ /** Padding inside the body */
+ padding?: CardPadding;
+}
+
+export const CardBody = forwardRef(function CardBody(
+ { padding = 'none', className = '', children, ...props },
+ ref
+) {
+ return (
+
+ {children}
+
+ );
+});
+
+/**
+ * CardFooter component for card footer content
+ *
+ * @example
+ * ```tsx
+ *
+ * Content
+ *
+ *
+ *
+ *
+ * ```
+ */
+export interface CardFooterProps extends HTMLAttributes {
+ /** Whether to add a border at the top */
+ bordered?: boolean;
+}
+
+export const CardFooter = forwardRef(function CardFooter(
+ { bordered = false, className = '', style, children, ...props },
+ ref
+) {
+ const { theme } = useTheme();
+ const colors = theme.colors;
+
+ return (
+
+ {children}
+
+ );
+});
+
+/**
+ * SessionCard component - A pre-composed card specifically for session items
+ *
+ * This is a convenience component that combines Card with common session display patterns.
+ *
+ * @example
+ * ```tsx
+ * selectSession(id)}
+ * />
+ * ```
+ */
+export type SessionStatus = 'idle' | 'busy' | 'error' | 'connecting';
+export type InputMode = 'ai' | 'terminal';
+
+export interface SessionCardProps extends Omit {
+ /** Session name */
+ name: string;
+ /** Session status */
+ status: SessionStatus;
+ /** Current input mode */
+ mode: InputMode;
+ /** Working directory path */
+ cwd?: string;
+ /** Status indicator element (optional, if you want custom indicator) */
+ statusIndicator?: ReactNode;
+ /** Additional info shown below the title */
+ info?: ReactNode;
+ /** Actions shown on the right side */
+ actions?: ReactNode;
+}
+
+/**
+ * Get status color based on session state
+ */
+const getStatusColor = (status: SessionStatus, colors: { success: string; warning: string; error: string }): string => {
+ switch (status) {
+ case 'idle':
+ return colors.success;
+ case 'busy':
+ return colors.warning;
+ case 'error':
+ return colors.error;
+ case 'connecting':
+ return '#f97316'; // Orange
+ default:
+ return colors.success;
+ }
+};
+
+export const SessionCard = forwardRef(function SessionCard(
+ {
+ name,
+ status,
+ mode,
+ cwd,
+ statusIndicator,
+ info,
+ actions,
+ variant = 'outlined',
+ ...props
+ },
+ ref
+) {
+ const { theme } = useTheme();
+ const colors = theme.colors;
+ const statusColor = getStatusColor(status, colors);
+
+ // Truncate cwd for display
+ const displayCwd = cwd ? (cwd.length > 30 ? '...' + cwd.slice(-27) : cwd) : undefined;
+
+ return (
+
+
+ {/* Status indicator */}
+ {statusIndicator || (
+
+ )}
+
+ {/* Main content */}
+
+
+
+ {name}
+
+
+ {mode === 'ai' ? 'AI' : 'Terminal'}
+
+
+ {(displayCwd || info) && (
+
+ {info || displayCwd}
+
+ )}
+
+
+ {/* Actions */}
+ {actions &&
{actions}
}
+
+
+ );
+});
+
+export default Card;
diff --git a/src/web/components/Input.tsx b/src/web/components/Input.tsx
new file mode 100644
index 000000000..4fdb5d169
--- /dev/null
+++ b/src/web/components/Input.tsx
@@ -0,0 +1,497 @@
+/**
+ * Input and TextArea components for Maestro web interface
+ *
+ * Reusable input components that support multiple variants, sizes, and states.
+ * Uses theme colors via CSS custom properties for consistent styling.
+ */
+
+import React, { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes, type ReactNode } from 'react';
+import { useTheme } from './ThemeProvider';
+
+/**
+ * Input variant types
+ * - default: Standard input with border
+ * - filled: Input with filled background
+ * - ghost: Minimal input with no border until focused
+ */
+export type InputVariant = 'default' | 'filled' | 'ghost';
+
+/**
+ * Input size options
+ */
+export type InputSize = 'sm' | 'md' | 'lg';
+
+/**
+ * Base props shared between Input and TextArea
+ */
+interface BaseInputProps {
+ /** Visual variant of the input */
+ variant?: InputVariant;
+ /** Size of the input */
+ size?: InputSize;
+ /** Whether the input has an error */
+ error?: boolean;
+ /** Whether the input should take full width */
+ fullWidth?: boolean;
+ /** Icon to display at the start of the input */
+ leftIcon?: ReactNode;
+ /** Icon to display at the end of the input */
+ rightIcon?: ReactNode;
+}
+
+export interface InputProps extends Omit, 'size'>, BaseInputProps {}
+
+export interface TextAreaProps extends Omit, 'size'>, Omit {
+ /** Minimum number of rows */
+ minRows?: number;
+ /** Maximum number of rows before scrolling */
+ maxRows?: number;
+ /** Whether to auto-resize based on content */
+ autoResize?: boolean;
+}
+
+/**
+ * Size-based style configurations
+ */
+const sizeStyles: Record = {
+ sm: {
+ className: 'px-2 py-1 text-xs',
+ borderRadius: '4px',
+ iconSize: 14,
+ },
+ md: {
+ className: 'px-3 py-1.5 text-sm',
+ borderRadius: '6px',
+ iconSize: 16,
+ },
+ lg: {
+ className: 'px-4 py-2 text-base',
+ borderRadius: '8px',
+ iconSize: 18,
+ },
+};
+
+/**
+ * Icon padding adjustments based on size
+ */
+const iconPadding: Record = {
+ sm: { left: 'pl-7', right: 'pr-7' },
+ md: { left: 'pl-9', right: 'pr-9' },
+ lg: { left: 'pl-11', right: 'pr-11' },
+};
+
+/**
+ * Input component for the Maestro web interface
+ *
+ * @example
+ * ```tsx
+ * // Basic input
+ *
+ *
+ * // Input with error state
+ *
+ *
+ * // Input with icons
+ * }
+ * placeholder="Search..."
+ * />
+ *
+ * // Filled variant
+ *
+ * ```
+ */
+export const Input = forwardRef(function Input(
+ {
+ variant = 'default',
+ size = 'md',
+ error = false,
+ fullWidth = false,
+ leftIcon,
+ rightIcon,
+ disabled,
+ className = '',
+ style,
+ ...props
+ },
+ ref
+) {
+ const { theme } = useTheme();
+ const colors = theme.colors;
+
+ const sizeConfig = sizeStyles[size];
+
+ /**
+ * Get variant-specific styles
+ */
+ const getVariantStyles = (): React.CSSProperties => {
+ const baseTransition = 'background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease';
+
+ switch (variant) {
+ case 'default':
+ return {
+ backgroundColor: colors.bgMain,
+ color: colors.textMain,
+ border: `1px solid ${error ? colors.error : colors.border}`,
+ transition: baseTransition,
+ };
+ case 'filled':
+ return {
+ backgroundColor: colors.bgActivity,
+ color: colors.textMain,
+ border: `1px solid ${error ? colors.error : 'transparent'}`,
+ transition: baseTransition,
+ };
+ case 'ghost':
+ return {
+ backgroundColor: 'transparent',
+ color: colors.textMain,
+ border: `1px solid ${error ? colors.error : 'transparent'}`,
+ transition: baseTransition,
+ };
+ default:
+ return {};
+ }
+ };
+
+ /**
+ * Get disabled styles
+ */
+ const getDisabledStyles = (): React.CSSProperties => {
+ if (!disabled) return {};
+ return {
+ opacity: 0.5,
+ cursor: 'not-allowed',
+ };
+ };
+
+ const variantStyles = getVariantStyles();
+ const disabledStyles = getDisabledStyles();
+
+ const combinedStyles: React.CSSProperties = {
+ ...variantStyles,
+ ...disabledStyles,
+ borderRadius: sizeConfig.borderRadius,
+ outline: 'none',
+ width: fullWidth ? '100%' : undefined,
+ ...style,
+ };
+
+ // Construct class names
+ const baseClasses = [
+ sizeConfig.className,
+ 'font-normal',
+ 'placeholder:text-opacity-50',
+ 'focus:ring-2 focus:ring-offset-1',
+ 'transition-colors',
+ fullWidth ? 'w-full' : '',
+ leftIcon ? iconPadding[size].left : '',
+ rightIcon ? iconPadding[size].right : '',
+ className,
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ // If we have icons, wrap in a container
+ if (leftIcon || rightIcon) {
+ return (
+
+ {leftIcon && (
+
+ {leftIcon}
+
+ )}
+
+ {rightIcon && (
+
+ {rightIcon}
+
+ )}
+
+ );
+ }
+
+ return (
+
+ );
+});
+
+/**
+ * TextArea component for the Maestro web interface
+ *
+ * @example
+ * ```tsx
+ * // Basic textarea
+ *
+ *
+ * // Auto-resizing textarea
+ *
+ *
+ * // Textarea with error
+ *
+ * ```
+ */
+export const TextArea = forwardRef(function TextArea(
+ {
+ variant = 'default',
+ size = 'md',
+ error = false,
+ fullWidth = false,
+ minRows = 3,
+ maxRows,
+ autoResize = false,
+ disabled,
+ className = '',
+ style,
+ onInput,
+ ...props
+ },
+ ref
+) {
+ const { theme } = useTheme();
+ const colors = theme.colors;
+
+ const sizeConfig = sizeStyles[size];
+
+ // Internal ref for auto-resize functionality
+ const textareaRef = React.useRef(null);
+
+ /**
+ * Get variant-specific styles
+ */
+ const getVariantStyles = (): React.CSSProperties => {
+ const baseTransition = 'background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease';
+
+ switch (variant) {
+ case 'default':
+ return {
+ backgroundColor: colors.bgMain,
+ color: colors.textMain,
+ border: `1px solid ${error ? colors.error : colors.border}`,
+ transition: baseTransition,
+ };
+ case 'filled':
+ return {
+ backgroundColor: colors.bgActivity,
+ color: colors.textMain,
+ border: `1px solid ${error ? colors.error : 'transparent'}`,
+ transition: baseTransition,
+ };
+ case 'ghost':
+ return {
+ backgroundColor: 'transparent',
+ color: colors.textMain,
+ border: `1px solid ${error ? colors.error : 'transparent'}`,
+ transition: baseTransition,
+ };
+ default:
+ return {};
+ }
+ };
+
+ /**
+ * Get disabled styles
+ */
+ const getDisabledStyles = (): React.CSSProperties => {
+ if (!disabled) return {};
+ return {
+ opacity: 0.5,
+ cursor: 'not-allowed',
+ };
+ };
+
+ /**
+ * Handle auto-resize on input
+ */
+ const handleInput = (e: React.FormEvent) => {
+ if (autoResize && textareaRef.current) {
+ const textarea = textareaRef.current;
+ // Reset height to auto to get the correct scrollHeight
+ textarea.style.height = 'auto';
+
+ // Calculate line height (approximate based on font size)
+ const lineHeight = size === 'sm' ? 16 : size === 'md' ? 20 : 24;
+ const minHeight = minRows * lineHeight;
+ const maxHeight = maxRows ? maxRows * lineHeight : undefined;
+
+ // Set the new height
+ let newHeight = Math.max(textarea.scrollHeight, minHeight);
+ if (maxHeight && newHeight > maxHeight) {
+ newHeight = maxHeight;
+ textarea.style.overflowY = 'auto';
+ } else {
+ textarea.style.overflowY = 'hidden';
+ }
+ textarea.style.height = `${newHeight}px`;
+ }
+
+ // Call the original onInput handler if provided
+ onInput?.(e);
+ };
+
+ /**
+ * Set up ref forwarding with internal ref
+ */
+ const setRefs = React.useCallback(
+ (element: HTMLTextAreaElement | null) => {
+ textareaRef.current = element;
+ if (typeof ref === 'function') {
+ ref(element);
+ } else if (ref) {
+ ref.current = element;
+ }
+ },
+ [ref]
+ );
+
+ const variantStyles = getVariantStyles();
+ const disabledStyles = getDisabledStyles();
+
+ // Calculate min-height based on minRows
+ const lineHeight = size === 'sm' ? 16 : size === 'md' ? 20 : 24;
+ const minHeight = minRows * lineHeight;
+
+ const combinedStyles: React.CSSProperties = {
+ ...variantStyles,
+ ...disabledStyles,
+ borderRadius: sizeConfig.borderRadius,
+ outline: 'none',
+ width: fullWidth ? '100%' : undefined,
+ minHeight: `${minHeight}px`,
+ resize: autoResize ? 'none' : 'vertical',
+ ...style,
+ };
+
+ // Construct class names
+ const classNames = [
+ sizeConfig.className,
+ 'font-normal',
+ 'placeholder:text-opacity-50',
+ 'focus:ring-2 focus:ring-offset-1',
+ 'transition-colors',
+ fullWidth ? 'w-full' : '',
+ className,
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ return (
+
+ );
+});
+
+/**
+ * InputGroup component for grouping label, input, and helper text
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ * ```
+ */
+export interface InputGroupProps {
+ /** Label text for the input */
+ label?: string;
+ /** Helper text shown below the input */
+ helperText?: string;
+ /** Error message (overrides helperText when present) */
+ error?: string;
+ /** Whether the field is required */
+ required?: boolean;
+ /** Children (typically Input or TextArea) */
+ children: ReactNode;
+ /** Additional class names for the container */
+ className?: string;
+}
+
+export function InputGroup({
+ label,
+ helperText,
+ error,
+ required,
+ children,
+ className = '',
+}: InputGroupProps) {
+ const { theme } = useTheme();
+ const colors = theme.colors;
+
+ return (
+
+ {label && (
+
+ )}
+ {children}
+ {(error || helperText) && (
+
+ {error || helperText}
+
+ )}
+
+ );
+}
+
+export default Input;
diff --git a/src/web/components/PullToRefresh.tsx b/src/web/components/PullToRefresh.tsx
new file mode 100644
index 000000000..b49c07a30
--- /dev/null
+++ b/src/web/components/PullToRefresh.tsx
@@ -0,0 +1,233 @@
+/**
+ * PullToRefresh Component for Maestro Mobile Web
+ *
+ * A visual indicator for pull-to-refresh functionality.
+ * Shows progress during pull and a spinner during refresh.
+ */
+
+import React from 'react';
+import { useThemeColors } from './ThemeProvider';
+
+export interface PullToRefreshIndicatorProps {
+ /** Current pull distance in pixels */
+ pullDistance: number;
+ /** Progress from 0 to 1 (1 = threshold reached) */
+ progress: number;
+ /** Whether currently refreshing */
+ isRefreshing: boolean;
+ /** Whether the threshold has been reached */
+ isThresholdReached: boolean;
+ /** Optional custom styles */
+ style?: React.CSSProperties;
+}
+
+/**
+ * Spinning refresh icon component
+ */
+function RefreshIcon({
+ size = 24,
+ color,
+ spinning = false,
+ progress = 1,
+}: {
+ size?: number;
+ color: string;
+ spinning?: boolean;
+ progress?: number;
+}) {
+ // Rotate based on progress or spin continuously when refreshing
+ const rotation = spinning ? 0 : progress * 360;
+ const animationStyle: React.CSSProperties = spinning
+ ? { animation: 'spin 1s linear infinite' }
+ : { transform: `rotate(${rotation}deg)` };
+
+ return (
+ <>
+ {spinning && (
+
+ )}
+
+ >
+ );
+}
+
+/**
+ * Arrow down icon for pull indicator
+ */
+function ArrowDownIcon({
+ size = 24,
+ color,
+ progress = 0,
+}: {
+ size?: number;
+ color: string;
+ progress?: number;
+}) {
+ // Rotate arrow to point up when threshold is reached
+ const rotation = progress >= 1 ? 180 : 0;
+
+ return (
+
+ );
+}
+
+/**
+ * Pull-to-refresh visual indicator component
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function PullToRefreshIndicator({
+ pullDistance,
+ progress,
+ isRefreshing,
+ isThresholdReached,
+ style,
+}: PullToRefreshIndicatorProps) {
+ const colors = useThemeColors();
+
+ // Don't render if not pulling and not refreshing
+ if (pullDistance === 0 && !isRefreshing) {
+ return null;
+ }
+
+ // Calculate opacity based on progress
+ const opacity = Math.min(progress * 1.5, 1);
+
+ // Calculate scale for a nice pop effect when threshold is reached
+ const scale = isThresholdReached || isRefreshing ? 1 : 0.8 + progress * 0.2;
+
+ // Background becomes more visible as you pull
+ const bgOpacity = Math.min(progress * 0.3, 0.2);
+
+ return (
+
+
+ {isRefreshing ? (
+
+ ) : (
+
+ )}
+
+ {isRefreshing
+ ? 'Refreshing...'
+ : isThresholdReached
+ ? 'Release to refresh'
+ : 'Pull to refresh'}
+
+
+
+ );
+}
+
+/**
+ * Wrapper component that provides pull-to-refresh functionality
+ * Combines the hook and indicator into one convenient component
+ */
+export interface PullToRefreshWrapperProps {
+ /** Called when pull-to-refresh is triggered */
+ onRefresh: () => Promise | void;
+ /** Whether pull-to-refresh is enabled (default: true) */
+ enabled?: boolean;
+ /** Children to render inside the scrollable container */
+ children: React.ReactNode;
+ /** Style for the outer container */
+ style?: React.CSSProperties;
+ /** Style for the scrollable content area */
+ contentStyle?: React.CSSProperties;
+ /** Additional class name for the container */
+ className?: string;
+}
+
+/**
+ * Helper function to convert hex color to RGB values
+ */
+function hexToRgb(hex: string): string {
+ // Remove # if present
+ const cleanHex = hex.replace('#', '');
+
+ // Parse hex values
+ const bigint = parseInt(cleanHex, 16);
+ const r = (bigint >> 16) & 255;
+ const g = (bigint >> 8) & 255;
+ const b = bigint & 255;
+
+ return `${r}, ${g}, ${b}`;
+}
+
+export default PullToRefreshIndicator;
diff --git a/src/web/components/ThemeProvider.tsx b/src/web/components/ThemeProvider.tsx
new file mode 100644
index 000000000..00ee58089
--- /dev/null
+++ b/src/web/components/ThemeProvider.tsx
@@ -0,0 +1,240 @@
+/**
+ * ThemeProvider component for Maestro web interface
+ *
+ * Provides theme context to web components. Accepts theme via props
+ * (typically received from WebSocket connection to desktop app).
+ * Automatically injects CSS custom properties for theme colors.
+ *
+ * Supports respecting device color scheme preference (dark/light mode)
+ * when no explicit theme override is provided from the desktop app.
+ */
+
+import React, { createContext, useContext, useEffect, useMemo } from 'react';
+import type { Theme, ThemeColors } from '../../shared/theme-types';
+import { injectCSSProperties, removeCSSProperties } from '../utils/cssCustomProperties';
+import { useDeviceColorScheme, type ColorSchemePreference } from '../hooks/useDeviceColorScheme';
+
+/**
+ * Context value containing the current theme and utility functions
+ */
+interface ThemeContextValue {
+ /** Current theme object */
+ theme: Theme;
+ /** Whether the theme is a light theme */
+ isLight: boolean;
+ /** Whether the theme is a dark theme */
+ isDark: boolean;
+ /** Whether the theme is a vibe theme */
+ isVibe: boolean;
+ /** Whether the theme is based on device preference (not overridden by desktop app) */
+ isDevicePreference: boolean;
+}
+
+/**
+ * Default dark theme used when device prefers dark mode or when we can't detect
+ * Matches the Dracula theme from the desktop app
+ */
+const defaultDarkTheme: Theme = {
+ id: 'dracula',
+ name: 'Dracula',
+ mode: 'dark',
+ colors: {
+ bgMain: '#0b0b0d',
+ bgSidebar: '#111113',
+ bgActivity: '#1c1c1f',
+ border: '#27272a',
+ textMain: '#e4e4e7',
+ textDim: '#a1a1aa',
+ accent: '#6366f1',
+ accentDim: 'rgba(99, 102, 241, 0.2)',
+ accentText: '#a5b4fc',
+ success: '#22c55e',
+ warning: '#eab308',
+ error: '#ef4444',
+ },
+};
+
+/**
+ * Default light theme used when device prefers light mode
+ * Matches the GitHub Light theme from the desktop app
+ */
+const defaultLightTheme: Theme = {
+ id: 'github-light',
+ name: 'GitHub',
+ mode: 'light',
+ colors: {
+ bgMain: '#ffffff',
+ bgSidebar: '#f6f8fa',
+ bgActivity: '#eff2f5',
+ border: '#d0d7de',
+ textMain: '#24292f',
+ textDim: '#57606a',
+ accent: '#0969da',
+ accentDim: 'rgba(9, 105, 218, 0.1)',
+ accentText: '#0969da',
+ success: '#1a7f37',
+ warning: '#9a6700',
+ error: '#cf222e',
+ },
+};
+
+/**
+ * Get the default theme based on device color scheme preference
+ */
+function getDefaultThemeForScheme(colorScheme: ColorSchemePreference): Theme {
+ return colorScheme === 'light' ? defaultLightTheme : defaultDarkTheme;
+}
+
+// Keep backwards compatibility - export defaultTheme as alias for defaultDarkTheme
+const defaultTheme = defaultDarkTheme;
+
+const ThemeContext = createContext(null);
+
+export interface ThemeProviderProps {
+ /**
+ * Theme object to provide to children.
+ * If not provided and useDevicePreference is true, uses theme based on device preference.
+ * If not provided and useDevicePreference is false, uses default dark theme.
+ */
+ theme?: Theme;
+ /**
+ * Whether to respect the device's color scheme preference (dark/light mode).
+ * When true and no theme prop is provided, the theme will automatically
+ * switch based on the user's device preference (prefers-color-scheme).
+ * When false, always uses the default dark theme if no theme is provided.
+ * @default false
+ */
+ useDevicePreference?: boolean;
+ /** Children components that will have access to the theme */
+ children: React.ReactNode;
+}
+
+/**
+ * ThemeProvider component that provides theme context to the component tree
+ *
+ * @example
+ * ```tsx
+ * // With theme from WebSocket
+ *
+ *
+ *
+ *
+ * // With device preference support (mobile web)
+ *
+ *
+ *
+ *
+ * // Using the context in a child component
+ * const { theme, isDark, isDevicePreference } = useTheme();
+ * ```
+ */
+export function ThemeProvider({
+ theme: themeProp,
+ useDevicePreference = false,
+ children,
+}: ThemeProviderProps) {
+ // Get device color scheme preference
+ const { colorScheme } = useDeviceColorScheme();
+
+ // Determine the active theme:
+ // 1. If a theme prop is provided (from desktop app), use it (override)
+ // 2. If useDevicePreference is true and no theme prop, use device preference
+ // 3. Otherwise, use default dark theme
+ const { activeTheme, isDevicePreference } = useMemo(() => {
+ // Theme prop provided - this is an override from desktop app
+ if (themeProp) {
+ return { activeTheme: themeProp, isDevicePreference: false };
+ }
+
+ // No theme prop - check if we should use device preference
+ if (useDevicePreference) {
+ return {
+ activeTheme: getDefaultThemeForScheme(colorScheme),
+ isDevicePreference: true,
+ };
+ }
+
+ // Default to dark theme
+ return { activeTheme: defaultDarkTheme, isDevicePreference: false };
+ }, [themeProp, useDevicePreference, colorScheme]);
+
+ const contextValue = useMemo(
+ () => ({
+ theme: activeTheme,
+ isLight: activeTheme.mode === 'light',
+ isDark: activeTheme.mode === 'dark',
+ isVibe: activeTheme.mode === 'vibe',
+ isDevicePreference,
+ }),
+ [activeTheme, isDevicePreference]
+ );
+
+ // Inject CSS custom properties whenever the theme changes
+ useEffect(() => {
+ injectCSSProperties(activeTheme);
+
+ // Cleanup on unmount
+ return () => {
+ removeCSSProperties();
+ };
+ }, [activeTheme]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Hook to access the current theme context
+ *
+ * @throws Error if used outside of a ThemeProvider
+ *
+ * @example
+ * ```tsx
+ * function MyComponent() {
+ * const { theme, isDark } = useTheme();
+ * return (
+ *
+ * {isDark ? 'Dark mode' : 'Light mode'}
+ *
+ * );
+ * }
+ * ```
+ */
+export function useTheme(): ThemeContextValue {
+ const context = useContext(ThemeContext);
+ if (!context) {
+ throw new Error('useTheme must be used within a ThemeProvider');
+ }
+ return context;
+}
+
+/**
+ * Hook to access just the theme colors for convenience
+ *
+ * @throws Error if used outside of a ThemeProvider
+ *
+ * @example
+ * ```tsx
+ * function Button() {
+ * const colors = useThemeColors();
+ * return (
+ *
+ * );
+ * }
+ * ```
+ */
+export function useThemeColors(): ThemeColors {
+ const { theme } = useTheme();
+ return theme.colors;
+}
+
+export { ThemeContext };
+export type { ThemeContextValue };
diff --git a/src/web/components/index.ts b/src/web/components/index.ts
new file mode 100644
index 000000000..f7b097182
--- /dev/null
+++ b/src/web/components/index.ts
@@ -0,0 +1,22 @@
+/**
+ * Web interface components for Maestro
+ *
+ * Shared components used by both mobile and desktop web interfaces.
+ */
+
+export {
+ ThemeProvider,
+ useTheme,
+ useThemeColors,
+ ThemeContext,
+} from './ThemeProvider';
+export type { ThemeProviderProps, ThemeContextValue } from './ThemeProvider';
+
+export { Button, IconButton } from './Button';
+export type { ButtonProps, ButtonVariant, ButtonSize, IconButtonProps } from './Button';
+
+export { Input, TextArea, InputGroup } from './Input';
+export type { InputProps, TextAreaProps, InputGroupProps, InputVariant, InputSize } from './Input';
+
+export { PullToRefreshIndicator } from './PullToRefresh';
+export type { PullToRefreshIndicatorProps } from './PullToRefresh';
diff --git a/src/web/hooks/index.ts b/src/web/hooks/index.ts
new file mode 100644
index 000000000..284e204aa
--- /dev/null
+++ b/src/web/hooks/index.ts
@@ -0,0 +1,133 @@
+/**
+ * Web interface hooks for Maestro
+ *
+ * Custom React hooks for the web interface, including WebSocket
+ * connection management and real-time state synchronization.
+ */
+
+export {
+ useWebSocket,
+ default as useWebSocketDefault,
+} from './useWebSocket';
+
+export type {
+ WebSocketState,
+ SessionData,
+ ServerMessageType,
+ ServerMessage,
+ ConnectedMessage,
+ AuthRequiredMessage,
+ AuthSuccessMessage,
+ AuthFailedMessage,
+ SessionsListMessage,
+ SessionStateChangeMessage,
+ SessionAddedMessage,
+ SessionRemovedMessage,
+ ThemeMessage,
+ ErrorMessage,
+ TypedServerMessage,
+ WebSocketEventHandlers,
+ UseWebSocketOptions,
+ UseWebSocketReturn,
+} from './useWebSocket';
+
+export {
+ useSessions,
+ default as useSessionsDefault,
+} from './useSessions';
+
+export type {
+ Session,
+ SessionState,
+ InputMode,
+ UseSessionsOptions,
+ UseSessionsReturn,
+} from './useSessions';
+
+export {
+ usePullToRefresh,
+ default as usePullToRefreshDefault,
+} from './usePullToRefresh';
+
+export type {
+ UsePullToRefreshOptions,
+ UsePullToRefreshReturn,
+} from './usePullToRefresh';
+
+export {
+ useCommandHistory,
+ default as useCommandHistoryDefault,
+} from './useCommandHistory';
+
+export type {
+ CommandHistoryEntry,
+ UseCommandHistoryOptions,
+ UseCommandHistoryReturn,
+} from './useCommandHistory';
+
+export {
+ useSwipeUp,
+ default as useSwipeUpDefault,
+} from './useSwipeUp';
+
+export type {
+ UseSwipeUpOptions,
+ UseSwipeUpReturn,
+} from './useSwipeUp';
+
+export {
+ useNotifications,
+ default as useNotificationsDefault,
+ isNotificationSupported,
+ getNotificationPermission,
+} from './useNotifications';
+
+export type {
+ NotificationPermission,
+ UseNotificationsOptions,
+ UseNotificationsReturn,
+} from './useNotifications';
+
+export {
+ useUnreadBadge,
+ default as useUnreadBadgeDefault,
+ isBadgeApiSupported,
+} from './useUnreadBadge';
+
+export type {
+ UseUnreadBadgeOptions,
+ UseUnreadBadgeReturn,
+} from './useUnreadBadge';
+
+export {
+ useSwipeGestures,
+ default as useSwipeGesturesDefault,
+} from './useSwipeGestures';
+
+export type {
+ SwipeDirection,
+ UseSwipeGesturesOptions,
+ UseSwipeGesturesReturn,
+} from './useSwipeGestures';
+
+export {
+ useOfflineQueue,
+ default as useOfflineQueueDefault,
+} from './useOfflineQueue';
+
+export type {
+ QueuedCommand,
+ QueueStatus,
+ UseOfflineQueueOptions,
+ UseOfflineQueueReturn,
+} from './useOfflineQueue';
+
+export {
+ useDeviceColorScheme,
+ default as useDeviceColorSchemeDefault,
+} from './useDeviceColorScheme';
+
+export type {
+ ColorSchemePreference,
+ UseDeviceColorSchemeReturn,
+} from './useDeviceColorScheme';
diff --git a/src/web/hooks/useCommandHistory.ts b/src/web/hooks/useCommandHistory.ts
new file mode 100644
index 000000000..cc011a027
--- /dev/null
+++ b/src/web/hooks/useCommandHistory.ts
@@ -0,0 +1,304 @@
+/**
+ * useCommandHistory hook for Maestro mobile web interface
+ *
+ * Manages command history storage and retrieval with localStorage persistence.
+ * Provides methods to add, remove, and navigate through command history.
+ */
+
+import { useState, useCallback, useEffect, useRef } from 'react';
+import { webLogger } from '../utils/logger';
+
+/** Maximum number of commands to store in history */
+const MAX_HISTORY_SIZE = 50;
+
+/** LocalStorage key for persisting command history */
+const STORAGE_KEY = 'maestro_command_history';
+
+export interface CommandHistoryEntry {
+ /** Unique identifier for the entry */
+ id: string;
+ /** The command text */
+ command: string;
+ /** Timestamp when the command was sent */
+ timestamp: number;
+ /** Session ID the command was sent to (optional) */
+ sessionId?: string;
+ /** Input mode when command was sent (ai or terminal) */
+ mode: 'ai' | 'terminal';
+}
+
+export interface UseCommandHistoryOptions {
+ /** Maximum number of commands to store (default: 50) */
+ maxSize?: number;
+ /** Whether to persist to localStorage (default: true) */
+ persist?: boolean;
+ /** Custom storage key (default: 'maestro_command_history') */
+ storageKey?: string;
+}
+
+export interface UseCommandHistoryReturn {
+ /** Array of command history entries (newest first) */
+ history: CommandHistoryEntry[];
+ /** Add a new command to history */
+ addCommand: (command: string, sessionId?: string, mode?: 'ai' | 'terminal') => void;
+ /** Remove a specific command from history by ID */
+ removeCommand: (id: string) => void;
+ /** Clear all command history */
+ clearHistory: () => void;
+ /** Get the most recent N commands (for quick-tap chips) */
+ getRecentCommands: (count?: number) => CommandHistoryEntry[];
+ /** Get unique commands (deduplicated, most recent first) */
+ getUniqueCommands: (count?: number) => CommandHistoryEntry[];
+ /** Search commands by text */
+ searchCommands: (query: string) => CommandHistoryEntry[];
+ /** Current history navigation index (-1 = not navigating) */
+ navigationIndex: number;
+ /** Navigate up in history (older commands) */
+ navigateUp: () => string | null;
+ /** Navigate down in history (newer commands) */
+ navigateDown: () => string | null;
+ /** Reset navigation position */
+ resetNavigation: () => void;
+}
+
+/**
+ * Generate a unique ID for a history entry
+ */
+function generateId(): string {
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+}
+
+/**
+ * Custom hook for managing command history
+ *
+ * @example
+ * ```tsx
+ * function CommandInput() {
+ * const { history, addCommand, getRecentCommands } = useCommandHistory();
+ *
+ * const handleSubmit = (command: string) => {
+ * addCommand(command, sessionId, 'ai');
+ * // ... send command
+ * };
+ *
+ * const recentCommands = getRecentCommands(5);
+ *
+ * return (
+ *
+ * {recentCommands.map(entry => (
+ *
+ * ))}
+ *
+ * );
+ * }
+ * ```
+ */
+export function useCommandHistory(
+ options: UseCommandHistoryOptions = {}
+): UseCommandHistoryReturn {
+ const {
+ maxSize = MAX_HISTORY_SIZE,
+ persist = true,
+ storageKey = STORAGE_KEY,
+ } = options;
+
+ const [history, setHistory] = useState([]);
+ const [navigationIndex, setNavigationIndex] = useState(-1);
+
+ // Track if initial load from storage has completed
+ const initialLoadDone = useRef(false);
+
+ // Load history from localStorage on mount
+ useEffect(() => {
+ if (!persist || typeof window === 'undefined') {
+ initialLoadDone.current = true;
+ return;
+ }
+
+ try {
+ const stored = localStorage.getItem(storageKey);
+ if (stored) {
+ const parsed = JSON.parse(stored) as CommandHistoryEntry[];
+ // Validate and clean up the data
+ const validEntries = parsed
+ .filter(
+ (entry) =>
+ entry &&
+ typeof entry.id === 'string' &&
+ typeof entry.command === 'string' &&
+ typeof entry.timestamp === 'number'
+ )
+ .slice(0, maxSize);
+ setHistory(validEntries);
+ }
+ } catch (error) {
+ webLogger.error('Failed to load from localStorage', 'CommandHistory', error);
+ }
+ initialLoadDone.current = true;
+ }, [persist, storageKey, maxSize]);
+
+ // Persist history to localStorage when it changes
+ useEffect(() => {
+ if (!persist || typeof window === 'undefined' || !initialLoadDone.current) {
+ return;
+ }
+
+ try {
+ localStorage.setItem(storageKey, JSON.stringify(history));
+ } catch (error) {
+ webLogger.error('Failed to save to localStorage', 'CommandHistory', error);
+ }
+ }, [history, persist, storageKey]);
+
+ /**
+ * Add a new command to history
+ */
+ const addCommand = useCallback(
+ (command: string, sessionId?: string, mode: 'ai' | 'terminal' = 'ai') => {
+ const trimmedCommand = command.trim();
+ if (!trimmedCommand) return;
+
+ const newEntry: CommandHistoryEntry = {
+ id: generateId(),
+ command: trimmedCommand,
+ timestamp: Date.now(),
+ sessionId,
+ mode,
+ };
+
+ setHistory((prev) => {
+ // Add new entry at the beginning
+ const updated = [newEntry, ...prev];
+ // Limit to max size
+ return updated.slice(0, maxSize);
+ });
+
+ // Reset navigation when adding new command
+ setNavigationIndex(-1);
+ },
+ [maxSize]
+ );
+
+ /**
+ * Remove a specific command from history
+ */
+ const removeCommand = useCallback((id: string) => {
+ setHistory((prev) => prev.filter((entry) => entry.id !== id));
+ // Reset navigation when modifying history
+ setNavigationIndex(-1);
+ }, []);
+
+ /**
+ * Clear all command history
+ */
+ const clearHistory = useCallback(() => {
+ setHistory([]);
+ setNavigationIndex(-1);
+ }, []);
+
+ /**
+ * Get the most recent N commands
+ */
+ const getRecentCommands = useCallback(
+ (count = 5): CommandHistoryEntry[] => {
+ return history.slice(0, count);
+ },
+ [history]
+ );
+
+ /**
+ * Normalize command for deduplication comparison
+ * - Lowercase
+ * - Trim whitespace
+ * - Remove trailing punctuation (?, !, .)
+ */
+ const normalizeForDedup = useCallback((command: string): string => {
+ return command.toLowerCase().trim().replace(/[?!.]+$/, '');
+ }, []);
+
+ /**
+ * Get unique commands (deduplicated by normalized text, most recent first)
+ * Deduplication ignores case and trailing punctuation
+ */
+ const getUniqueCommands = useCallback(
+ (count = 5): CommandHistoryEntry[] => {
+ const seen = new Set();
+ const unique: CommandHistoryEntry[] = [];
+
+ for (const entry of history) {
+ const normalized = normalizeForDedup(entry.command);
+ if (!seen.has(normalized)) {
+ seen.add(normalized);
+ unique.push(entry);
+ if (unique.length >= count) break;
+ }
+ }
+
+ return unique;
+ },
+ [history, normalizeForDedup]
+ );
+
+ /**
+ * Search commands by text (case-insensitive)
+ */
+ const searchCommands = useCallback(
+ (query: string): CommandHistoryEntry[] => {
+ const lowerQuery = query.toLowerCase();
+ return history.filter((entry) =>
+ entry.command.toLowerCase().includes(lowerQuery)
+ );
+ },
+ [history]
+ );
+
+ /**
+ * Navigate up in history (older commands)
+ */
+ const navigateUp = useCallback((): string | null => {
+ if (history.length === 0) return null;
+
+ const newIndex = Math.min(navigationIndex + 1, history.length - 1);
+ setNavigationIndex(newIndex);
+ return history[newIndex]?.command ?? null;
+ }, [history, navigationIndex]);
+
+ /**
+ * Navigate down in history (newer commands)
+ */
+ const navigateDown = useCallback((): string | null => {
+ if (navigationIndex <= 0) {
+ setNavigationIndex(-1);
+ return null;
+ }
+
+ const newIndex = navigationIndex - 1;
+ setNavigationIndex(newIndex);
+ return history[newIndex]?.command ?? null;
+ }, [history, navigationIndex]);
+
+ /**
+ * Reset navigation position
+ */
+ const resetNavigation = useCallback(() => {
+ setNavigationIndex(-1);
+ }, []);
+
+ return {
+ history,
+ addCommand,
+ removeCommand,
+ clearHistory,
+ getRecentCommands,
+ getUniqueCommands,
+ searchCommands,
+ navigationIndex,
+ navigateUp,
+ navigateDown,
+ resetNavigation,
+ };
+}
+
+export default useCommandHistory;
diff --git a/src/web/hooks/useDeviceColorScheme.ts b/src/web/hooks/useDeviceColorScheme.ts
new file mode 100644
index 000000000..2be99ceb0
--- /dev/null
+++ b/src/web/hooks/useDeviceColorScheme.ts
@@ -0,0 +1,105 @@
+/**
+ * useDeviceColorScheme hook for Maestro web interface
+ *
+ * Detects and tracks the device's preferred color scheme (dark/light mode).
+ * Uses the `prefers-color-scheme` media query to determine user preference.
+ * Listens for changes so the UI can update dynamically if the user changes
+ * their device settings.
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+
+/**
+ * Device color scheme preferences
+ */
+export type ColorSchemePreference = 'light' | 'dark';
+
+/**
+ * Return value from useDeviceColorScheme hook
+ */
+export interface UseDeviceColorSchemeReturn {
+ /** The device's current color scheme preference */
+ colorScheme: ColorSchemePreference;
+ /** Whether the device prefers dark mode */
+ prefersDark: boolean;
+ /** Whether the device prefers light mode */
+ prefersLight: boolean;
+}
+
+/**
+ * Detect the initial color scheme from the media query
+ */
+function getInitialColorScheme(): ColorSchemePreference {
+ // Check if window and matchMedia are available (SSR safety)
+ if (typeof window === 'undefined' || !window.matchMedia) {
+ return 'dark'; // Default to dark when we can't detect
+ }
+
+ // Check if the user prefers dark mode
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ return prefersDark ? 'dark' : 'light';
+}
+
+/**
+ * Hook to detect and track the device's preferred color scheme
+ *
+ * @example
+ * ```tsx
+ * function App() {
+ * const { prefersDark, colorScheme } = useDeviceColorScheme();
+ *
+ * return (
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export function useDeviceColorScheme(): UseDeviceColorSchemeReturn {
+ const [colorScheme, setColorScheme] = useState(getInitialColorScheme);
+
+ useEffect(() => {
+ // Check if matchMedia is available
+ if (typeof window === 'undefined' || !window.matchMedia) {
+ return;
+ }
+
+ // Create media query for dark mode preference
+ const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
+
+ // Handler for when the preference changes
+ const handleChange = (event: MediaQueryListEvent) => {
+ setColorScheme(event.matches ? 'dark' : 'light');
+ };
+
+ // Add event listener for changes
+ // Use addEventListener if available (modern browsers), otherwise use deprecated addListener
+ if (darkModeQuery.addEventListener) {
+ darkModeQuery.addEventListener('change', handleChange);
+ } else if (darkModeQuery.addListener) {
+ // Fallback for older Safari versions
+ darkModeQuery.addListener(handleChange);
+ }
+
+ // Cleanup
+ return () => {
+ if (darkModeQuery.removeEventListener) {
+ darkModeQuery.removeEventListener('change', handleChange);
+ } else if (darkModeQuery.removeListener) {
+ // Fallback for older Safari versions
+ darkModeQuery.removeListener(handleChange);
+ }
+ };
+ }, []);
+
+ return {
+ colorScheme,
+ prefersDark: colorScheme === 'dark',
+ prefersLight: colorScheme === 'light',
+ };
+}
+
+export default useDeviceColorScheme;
diff --git a/src/web/hooks/useNotifications.ts b/src/web/hooks/useNotifications.ts
new file mode 100644
index 000000000..701a3cd33
--- /dev/null
+++ b/src/web/hooks/useNotifications.ts
@@ -0,0 +1,255 @@
+/**
+ * Notification Hook for Maestro Mobile Web
+ *
+ * Handles notification permission requests and push notification
+ * functionality for the mobile web interface.
+ *
+ * Features:
+ * - Request notification permission on first visit
+ * - Track permission state
+ * - Persist permission request state to avoid repeated prompts
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import { webLogger } from '../utils/logger';
+
+/**
+ * Notification permission states
+ */
+export type NotificationPermission = 'default' | 'granted' | 'denied';
+
+/**
+ * Storage key for tracking if we've asked for permission before
+ */
+const NOTIFICATION_PROMPT_KEY = 'maestro_notification_prompted';
+
+/**
+ * Storage key for user preference (if they explicitly declined)
+ */
+const NOTIFICATION_DECLINED_KEY = 'maestro_notification_declined';
+
+/**
+ * Configuration options for the useNotifications hook
+ */
+export interface UseNotificationsOptions {
+ /** Whether to automatically request permission on first visit (default: true) */
+ autoRequest?: boolean;
+ /** Delay in ms before showing permission prompt (default: 2000) */
+ requestDelay?: number;
+ /** Callback when permission is granted */
+ onGranted?: () => void;
+ /** Callback when permission is denied */
+ onDenied?: () => void;
+ /** Callback when permission state changes */
+ onPermissionChange?: (permission: NotificationPermission) => void;
+}
+
+/**
+ * Return type for the useNotifications hook
+ */
+export interface UseNotificationsReturn {
+ /** Current notification permission state */
+ permission: NotificationPermission;
+ /** Whether notifications are supported in this browser */
+ isSupported: boolean;
+ /** Whether we've already prompted the user */
+ hasPrompted: boolean;
+ /** Whether the user explicitly declined notifications */
+ hasDeclined: boolean;
+ /** Request notification permission */
+ requestPermission: () => Promise;
+ /** Mark as declined (user explicitly said no in our UI) */
+ declineNotifications: () => void;
+ /** Reset the prompt state (allows re-prompting) */
+ resetPromptState: () => void;
+ /** Show a notification (if permission granted) */
+ showNotification: (title: string, options?: NotificationOptions) => Notification | null;
+}
+
+/**
+ * Check if notifications are supported
+ */
+export function isNotificationSupported(): boolean {
+ return typeof window !== 'undefined' && 'Notification' in window;
+}
+
+/**
+ * Get the current notification permission
+ */
+export function getNotificationPermission(): NotificationPermission {
+ if (!isNotificationSupported()) return 'denied';
+ return Notification.permission as NotificationPermission;
+}
+
+/**
+ * Hook for managing notification permissions and displaying notifications
+ *
+ * @param options - Configuration options
+ * @returns Notification state and control functions
+ */
+export function useNotifications(
+ options: UseNotificationsOptions = {}
+): UseNotificationsReturn {
+ const {
+ autoRequest = true,
+ requestDelay = 2000,
+ onGranted,
+ onDenied,
+ onPermissionChange,
+ } = options;
+
+ const isSupported = isNotificationSupported();
+
+ const [permission, setPermission] = useState(
+ getNotificationPermission()
+ );
+
+ const [hasPrompted, setHasPrompted] = useState(() => {
+ if (typeof localStorage === 'undefined') return false;
+ return localStorage.getItem(NOTIFICATION_PROMPT_KEY) === 'true';
+ });
+
+ const [hasDeclined, setHasDeclined] = useState(() => {
+ if (typeof localStorage === 'undefined') return false;
+ return localStorage.getItem(NOTIFICATION_DECLINED_KEY) === 'true';
+ });
+
+ /**
+ * Request notification permission from the user
+ */
+ const requestPermission = useCallback(async (): Promise => {
+ if (!isSupported) {
+ webLogger.debug('Notifications not supported in this browser', 'Notifications');
+ return 'denied';
+ }
+
+ // Mark that we've prompted the user
+ setHasPrompted(true);
+ localStorage.setItem(NOTIFICATION_PROMPT_KEY, 'true');
+
+ try {
+ const result = await Notification.requestPermission();
+ const newPermission = result as NotificationPermission;
+
+ setPermission(newPermission);
+ onPermissionChange?.(newPermission);
+
+ if (newPermission === 'granted') {
+ webLogger.debug('Permission granted', 'Notifications');
+ onGranted?.();
+ } else if (newPermission === 'denied') {
+ webLogger.debug('Permission denied', 'Notifications');
+ onDenied?.();
+ }
+
+ return newPermission;
+ } catch (error) {
+ webLogger.error('Error requesting permission', 'Notifications', error);
+ return 'denied';
+ }
+ }, [isSupported, onGranted, onDenied, onPermissionChange]);
+
+ /**
+ * Mark notifications as explicitly declined by user (via our UI)
+ */
+ const declineNotifications = useCallback(() => {
+ setHasDeclined(true);
+ setHasPrompted(true);
+ localStorage.setItem(NOTIFICATION_DECLINED_KEY, 'true');
+ localStorage.setItem(NOTIFICATION_PROMPT_KEY, 'true');
+ webLogger.debug('User declined via UI', 'Notifications');
+ }, []);
+
+ /**
+ * Reset the prompt state to allow re-prompting
+ */
+ const resetPromptState = useCallback(() => {
+ setHasPrompted(false);
+ setHasDeclined(false);
+ localStorage.removeItem(NOTIFICATION_PROMPT_KEY);
+ localStorage.removeItem(NOTIFICATION_DECLINED_KEY);
+ webLogger.debug('Prompt state reset', 'Notifications');
+ }, []);
+
+ /**
+ * Show a notification (if permission is granted)
+ */
+ const showNotification = useCallback(
+ (title: string, options?: NotificationOptions): Notification | null => {
+ if (!isSupported || permission !== 'granted') {
+ webLogger.debug(`Cannot show notification, permission: ${permission}`, 'Notifications');
+ return null;
+ }
+
+ try {
+ const notification = new Notification(title, {
+ icon: '/maestro-icon-192.png',
+ badge: '/maestro-icon-192.png',
+ ...options,
+ });
+
+ return notification;
+ } catch (error) {
+ webLogger.error('Error showing notification', 'Notifications', error);
+ return null;
+ }
+ },
+ [isSupported, permission]
+ );
+
+ // Auto-request permission on first visit after a delay
+ useEffect(() => {
+ if (!autoRequest || !isSupported) return;
+
+ // Don't prompt if already prompted or explicitly declined
+ if (hasPrompted || hasDeclined) return;
+
+ // Don't prompt if already granted or denied at browser level
+ if (permission !== 'default') return;
+
+ // Wait for the specified delay before prompting
+ const timer = setTimeout(() => {
+ webLogger.debug('Auto-requesting permission after delay', 'Notifications');
+ requestPermission();
+ }, requestDelay);
+
+ return () => clearTimeout(timer);
+ }, [autoRequest, isSupported, hasPrompted, hasDeclined, permission, requestDelay, requestPermission]);
+
+ // Listen for permission changes (e.g., user changes in browser settings)
+ useEffect(() => {
+ if (!isSupported) return;
+
+ // Check permission periodically (some browsers don't have an event for this)
+ const checkPermission = () => {
+ const currentPermission = getNotificationPermission();
+ if (currentPermission !== permission) {
+ setPermission(currentPermission);
+ onPermissionChange?.(currentPermission);
+ }
+ };
+
+ // Check on visibility change (user may have changed settings in another tab)
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'visible') {
+ checkPermission();
+ }
+ };
+
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+ return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
+ }, [isSupported, permission, onPermissionChange]);
+
+ return {
+ permission,
+ isSupported,
+ hasPrompted,
+ hasDeclined,
+ requestPermission,
+ declineNotifications,
+ resetPromptState,
+ showNotification,
+ };
+}
+
+export default useNotifications;
diff --git a/src/web/hooks/useOfflineQueue.ts b/src/web/hooks/useOfflineQueue.ts
new file mode 100644
index 000000000..0ee286379
--- /dev/null
+++ b/src/web/hooks/useOfflineQueue.ts
@@ -0,0 +1,395 @@
+/**
+ * useOfflineQueue hook for Maestro web interface
+ *
+ * Provides offline command queueing functionality that stores commands
+ * typed while offline and automatically sends them when reconnected.
+ *
+ * Features:
+ * - Persists queued commands to localStorage for survival across page reloads
+ * - Automatically sends queued commands when connection is restored
+ * - Tracks queue status and provides progress feedback
+ * - Allows manual retry and clearing of queued commands
+ * - Handles partial queue failures gracefully
+ */
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { webLogger } from '../utils/logger';
+
+/** Storage key for persisting offline queue */
+const STORAGE_KEY = 'maestro-offline-queue';
+
+/** Maximum number of commands to queue (prevent unbounded growth) */
+const MAX_QUEUE_SIZE = 50;
+
+/** Delay between sending queued commands (ms) */
+const SEND_DELAY = 100;
+
+/**
+ * Queued command entry
+ */
+export interface QueuedCommand {
+ /** Unique ID for the queued command */
+ id: string;
+ /** The command text */
+ command: string;
+ /** Target session ID */
+ sessionId: string;
+ /** Timestamp when command was queued */
+ timestamp: number;
+ /** Input mode (ai or terminal) */
+ inputMode: 'ai' | 'terminal';
+ /** Number of send attempts */
+ attempts: number;
+ /** Last error message if send failed */
+ lastError?: string;
+}
+
+/**
+ * Queue processing status
+ */
+export type QueueStatus = 'idle' | 'processing' | 'paused';
+
+/**
+ * Options for the useOfflineQueue hook
+ */
+export interface UseOfflineQueueOptions {
+ /** Whether the device is currently online */
+ isOnline: boolean;
+ /** Whether connected to the WebSocket server */
+ isConnected: boolean;
+ /** Function to send a command to the server */
+ sendCommand: (sessionId: string, command: string) => boolean;
+ /** Maximum retry attempts per command (default: 3) */
+ maxRetries?: number;
+ /** Callback when a queued command is successfully sent */
+ onCommandSent?: (command: QueuedCommand) => void;
+ /** Callback when a queued command fails after all retries */
+ onCommandFailed?: (command: QueuedCommand, error: string) => void;
+ /** Callback when queue processing starts */
+ onProcessingStart?: () => void;
+ /** Callback when queue processing completes */
+ onProcessingComplete?: (successCount: number, failCount: number) => void;
+}
+
+/**
+ * Return value from useOfflineQueue hook
+ */
+export interface UseOfflineQueueReturn {
+ /** Current queued commands */
+ queue: QueuedCommand[];
+ /** Number of commands in queue */
+ queueLength: number;
+ /** Whether the queue is currently being processed */
+ status: QueueStatus;
+ /** Add a command to the queue (call when offline) */
+ queueCommand: (sessionId: string, command: string, inputMode: 'ai' | 'terminal') => QueuedCommand | null;
+ /** Remove a specific command from the queue */
+ removeCommand: (commandId: string) => void;
+ /** Clear all commands from the queue */
+ clearQueue: () => void;
+ /** Manually trigger queue processing */
+ processQueue: () => Promise;
+ /** Pause queue processing */
+ pauseProcessing: () => void;
+ /** Resume queue processing */
+ resumeProcessing: () => void;
+ /** Check if a command can be queued (not at max capacity) */
+ canQueue: boolean;
+}
+
+/**
+ * Generate a unique ID for queued commands
+ */
+function generateId(): string {
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+}
+
+/**
+ * Load queue from localStorage
+ */
+function loadQueue(): QueuedCommand[] {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ if (Array.isArray(parsed)) {
+ return parsed;
+ }
+ }
+ } catch (error) {
+ webLogger.warn('Failed to load queue from storage', 'OfflineQueue', error);
+ }
+ return [];
+}
+
+/**
+ * Save queue to localStorage
+ */
+function saveQueue(queue: QueuedCommand[]): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(queue));
+ } catch (error) {
+ webLogger.warn('Failed to save queue to storage', 'OfflineQueue', error);
+ }
+}
+
+/**
+ * useOfflineQueue hook for managing offline command queueing
+ *
+ * @example
+ * ```tsx
+ * function MobileApp() {
+ * const { queue, queueLength, queueCommand, status } = useOfflineQueue({
+ * isOnline: navigator.onLine,
+ * isConnected: wsState === 'authenticated',
+ * sendCommand: (sessionId, command) => {
+ * return send({ type: 'send_command', sessionId, command });
+ * },
+ * onCommandSent: (cmd) => {
+ * console.log('Queued command sent:', cmd.command);
+ * },
+ * });
+ *
+ * const handleSubmit = (command: string) => {
+ * if (!isOnline || !isConnected) {
+ * // Queue for later
+ * queueCommand(activeSessionId, command, inputMode);
+ * } else {
+ * // Send immediately
+ * sendCommand(activeSessionId, command);
+ * }
+ * };
+ *
+ * return (
+ *
+ * {queueLength > 0 && (
+ * {queueLength} command(s) queued
+ * )}
+ *
+ * );
+ * }
+ * ```
+ */
+export function useOfflineQueue(options: UseOfflineQueueOptions): UseOfflineQueueReturn {
+ const {
+ isOnline,
+ isConnected,
+ sendCommand,
+ maxRetries = 3,
+ onCommandSent,
+ onCommandFailed,
+ onProcessingStart,
+ onProcessingComplete,
+ } = options;
+
+ // State
+ const [queue, setQueue] = useState(() => loadQueue());
+ const [status, setStatus] = useState('idle');
+
+ // Refs for async processing
+ const isProcessingRef = useRef(false);
+ const isPausedRef = useRef(false);
+ const sendCommandRef = useRef(sendCommand);
+
+ // Keep sendCommand ref up to date
+ useEffect(() => {
+ sendCommandRef.current = sendCommand;
+ }, [sendCommand]);
+
+ /**
+ * Save queue to localStorage whenever it changes
+ */
+ useEffect(() => {
+ saveQueue(queue);
+ }, [queue]);
+
+ /**
+ * Queue a command for later sending
+ */
+ const queueCommand = useCallback(
+ (sessionId: string, command: string, inputMode: 'ai' | 'terminal'): QueuedCommand | null => {
+ // Check if we're at capacity
+ if (queue.length >= MAX_QUEUE_SIZE) {
+ webLogger.warn('Queue at maximum capacity, cannot add more commands', 'OfflineQueue');
+ return null;
+ }
+
+ const newCommand: QueuedCommand = {
+ id: generateId(),
+ command,
+ sessionId,
+ timestamp: Date.now(),
+ inputMode,
+ attempts: 0,
+ };
+
+ setQueue(prev => [...prev, newCommand]);
+ webLogger.debug(`Command queued: ${command.substring(0, 50)}`, 'OfflineQueue');
+
+ return newCommand;
+ },
+ [queue.length]
+ );
+
+ /**
+ * Remove a specific command from the queue
+ */
+ const removeCommand = useCallback((commandId: string) => {
+ setQueue(prev => prev.filter(cmd => cmd.id !== commandId));
+ webLogger.debug(`Command removed: ${commandId}`, 'OfflineQueue');
+ }, []);
+
+ /**
+ * Clear all commands from the queue
+ */
+ const clearQueue = useCallback(() => {
+ setQueue([]);
+ webLogger.debug('Queue cleared', 'OfflineQueue');
+ }, []);
+
+ /**
+ * Process the queue - send all queued commands
+ */
+ const processQueue = useCallback(async () => {
+ // Don't start if already processing or paused
+ if (isProcessingRef.current || isPausedRef.current) {
+ return;
+ }
+
+ // Don't process if not connected
+ if (!isOnline || !isConnected) {
+ webLogger.debug('Cannot process queue - not connected', 'OfflineQueue');
+ return;
+ }
+
+ // Don't process empty queue
+ if (queue.length === 0) {
+ return;
+ }
+
+ isProcessingRef.current = true;
+ setStatus('processing');
+ onProcessingStart?.();
+
+ webLogger.debug(`Starting queue processing, items: ${queue.length}`, 'OfflineQueue');
+
+ let successCount = 0;
+ let failCount = 0;
+ const failedCommands: QueuedCommand[] = [];
+
+ // Process each command sequentially
+ for (const cmd of queue) {
+ // Check if processing was paused
+ if (isPausedRef.current) {
+ webLogger.debug('Processing paused', 'OfflineQueue');
+ failedCommands.push(cmd);
+ continue;
+ }
+
+ // Check if still connected
+ if (!isOnline || !isConnected) {
+ webLogger.debug('Lost connection during processing', 'OfflineQueue');
+ failedCommands.push(cmd);
+ continue;
+ }
+
+ // Attempt to send the command
+ const updatedCmd = { ...cmd, attempts: cmd.attempts + 1 };
+
+ try {
+ const success = sendCommandRef.current(cmd.sessionId, cmd.command);
+
+ if (success) {
+ successCount++;
+ webLogger.debug(`Command sent successfully: ${cmd.command.substring(0, 50)}`, 'OfflineQueue');
+ onCommandSent?.(updatedCmd);
+ } else {
+ // Send returned false - likely disconnected
+ if (updatedCmd.attempts < maxRetries) {
+ updatedCmd.lastError = 'Send failed - will retry';
+ failedCommands.push(updatedCmd);
+ } else {
+ failCount++;
+ updatedCmd.lastError = 'Max retries exceeded';
+ onCommandFailed?.(updatedCmd, 'Max retries exceeded');
+ }
+ }
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
+ if (updatedCmd.attempts < maxRetries) {
+ updatedCmd.lastError = errorMsg;
+ failedCommands.push(updatedCmd);
+ } else {
+ failCount++;
+ updatedCmd.lastError = errorMsg;
+ onCommandFailed?.(updatedCmd, errorMsg);
+ }
+ }
+
+ // Small delay between commands to avoid overwhelming the server
+ await new Promise(resolve => setTimeout(resolve, SEND_DELAY));
+ }
+
+ // Update queue with any failed commands that should be retried
+ setQueue(failedCommands);
+
+ isProcessingRef.current = false;
+ setStatus(isPausedRef.current ? 'paused' : 'idle');
+
+ webLogger.debug(`Processing complete. Success: ${successCount}, Failed: ${failCount}`, 'OfflineQueue');
+ onProcessingComplete?.(successCount, failCount);
+ }, [isOnline, isConnected, queue, maxRetries, onCommandSent, onCommandFailed, onProcessingStart, onProcessingComplete]);
+
+ /**
+ * Pause queue processing
+ */
+ const pauseProcessing = useCallback(() => {
+ isPausedRef.current = true;
+ setStatus('paused');
+ webLogger.debug('Processing paused', 'OfflineQueue');
+ }, []);
+
+ /**
+ * Resume queue processing
+ */
+ const resumeProcessing = useCallback(() => {
+ isPausedRef.current = false;
+ if (!isProcessingRef.current) {
+ setStatus('idle');
+ }
+ webLogger.debug('Processing resumed', 'OfflineQueue');
+ // Trigger processing if there are queued items
+ if (queue.length > 0 && isOnline && isConnected) {
+ processQueue();
+ }
+ }, [queue.length, isOnline, isConnected, processQueue]);
+
+ /**
+ * Automatically process queue when connection is restored
+ */
+ useEffect(() => {
+ if (isOnline && isConnected && queue.length > 0 && !isPausedRef.current) {
+ // Small delay to ensure connection is stable
+ const timer = setTimeout(() => {
+ processQueue();
+ }, 500);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isOnline, isConnected, queue.length, processQueue]);
+
+ return {
+ queue,
+ queueLength: queue.length,
+ status,
+ queueCommand,
+ removeCommand,
+ clearQueue,
+ processQueue,
+ pauseProcessing,
+ resumeProcessing,
+ canQueue: queue.length < MAX_QUEUE_SIZE,
+ };
+}
+
+export default useOfflineQueue;
diff --git a/src/web/hooks/usePullToRefresh.ts b/src/web/hooks/usePullToRefresh.ts
new file mode 100644
index 000000000..37291eb98
--- /dev/null
+++ b/src/web/hooks/usePullToRefresh.ts
@@ -0,0 +1,203 @@
+/**
+ * usePullToRefresh hook for Maestro mobile web interface
+ *
+ * Provides touch gesture handling for pull-to-refresh functionality.
+ * Tracks touch events and determines when to trigger a refresh.
+ */
+
+import { useState, useCallback, useRef, useEffect } from 'react';
+// Import from constants directly to avoid circular dependency with mobile/index.tsx
+import { GESTURE_THRESHOLDS } from '../mobile/constants';
+import { webLogger } from '../utils/logger';
+
+export interface UsePullToRefreshOptions {
+ /** Called when pull-to-refresh is triggered */
+ onRefresh: () => Promise | void;
+ /** Distance in pixels required to trigger refresh (default: 80) */
+ threshold?: number;
+ /** Maximum distance the pull indicator can travel (default: 150) */
+ maxPull?: number;
+ /** Whether pull-to-refresh is enabled (default: true) */
+ enabled?: boolean;
+ /** Element ref to attach handlers to (uses document if not provided) */
+ containerRef?: React.RefObject;
+}
+
+export interface UsePullToRefreshReturn {
+ /** Current pull distance in pixels */
+ pullDistance: number;
+ /** Whether the threshold has been reached */
+ isThresholdReached: boolean;
+ /** Whether currently refreshing */
+ isRefreshing: boolean;
+ /** Progress from 0 to 1 (1 = threshold reached) */
+ progress: number;
+ /** Props to spread on the scrollable container */
+ containerProps: {
+ onTouchStart: (e: React.TouchEvent) => void;
+ onTouchMove: (e: React.TouchEvent) => void;
+ onTouchEnd: (e: React.TouchEvent) => void;
+ };
+}
+
+/**
+ * Custom hook for implementing pull-to-refresh gesture
+ *
+ * @example
+ * ```tsx
+ * function SessionList() {
+ * const { refreshSessions } = useSessions();
+ * const {
+ * pullDistance,
+ * isRefreshing,
+ * progress,
+ * containerProps
+ * } = usePullToRefresh({
+ * onRefresh: refreshSessions,
+ * });
+ *
+ * return (
+ *
+ *
+ * {sessions.map(session =>
)}
+ *
+ * );
+ * }
+ * ```
+ */
+export function usePullToRefresh(options: UsePullToRefreshOptions): UsePullToRefreshReturn {
+ const {
+ onRefresh,
+ threshold = GESTURE_THRESHOLDS.pullToRefresh,
+ maxPull = 150,
+ enabled = true,
+ } = options;
+
+ const [pullDistance, setPullDistance] = useState(0);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ // Refs to track touch state
+ const touchStartY = useRef(0);
+ const touchStartX = useRef(0);
+ const isPulling = useRef(false);
+ const isScrolledToTop = useRef(true);
+
+ // Callback refs to avoid stale closures
+ const onRefreshRef = useRef(onRefresh);
+ useEffect(() => {
+ onRefreshRef.current = onRefresh;
+ }, [onRefresh]);
+
+ /**
+ * Check if the container is scrolled to the top
+ */
+ const checkScrollTop = useCallback((element: HTMLElement | null): boolean => {
+ if (!element) return true;
+ return element.scrollTop <= 0;
+ }, []);
+
+ /**
+ * Handle touch start
+ */
+ const handleTouchStart = useCallback(
+ (e: React.TouchEvent) => {
+ if (!enabled || isRefreshing) return;
+
+ const touch = e.touches[0];
+ touchStartY.current = touch.clientY;
+ touchStartX.current = touch.clientX;
+
+ // Check if we're at the top of the scroll container
+ const target = e.currentTarget as HTMLElement;
+ isScrolledToTop.current = checkScrollTop(target);
+ },
+ [enabled, isRefreshing, checkScrollTop]
+ );
+
+ /**
+ * Handle touch move
+ */
+ const handleTouchMove = useCallback(
+ (e: React.TouchEvent) => {
+ if (!enabled || isRefreshing) return;
+
+ const touch = e.touches[0];
+ const deltaY = touch.clientY - touchStartY.current;
+ const deltaX = touch.clientX - touchStartX.current;
+
+ // Only trigger pull-to-refresh if:
+ // 1. We're at the top of the scroll container
+ // 2. We're pulling down (deltaY > 0)
+ // 3. The movement is more vertical than horizontal
+ if (
+ isScrolledToTop.current &&
+ deltaY > 0 &&
+ Math.abs(deltaY) > Math.abs(deltaX)
+ ) {
+ // Mark as pulling
+ isPulling.current = true;
+
+ // Calculate pull distance with resistance (diminishing returns)
+ const resistance = 0.5;
+ const adjustedDelta = Math.min(deltaY * resistance, maxPull);
+ setPullDistance(adjustedDelta);
+
+ // Prevent default scroll behavior while pulling
+ if (adjustedDelta > 10) {
+ e.preventDefault();
+ }
+ }
+ },
+ [enabled, isRefreshing, maxPull]
+ );
+
+ /**
+ * Handle touch end
+ */
+ const handleTouchEnd = useCallback(
+ async (e: React.TouchEvent) => {
+ if (!enabled || isRefreshing || !isPulling.current) {
+ isPulling.current = false;
+ return;
+ }
+
+ isPulling.current = false;
+
+ if (pullDistance >= threshold) {
+ // Threshold reached - trigger refresh
+ setIsRefreshing(true);
+
+ try {
+ await onRefreshRef.current();
+ } catch (error) {
+ webLogger.error('Refresh error', 'PullToRefresh', error);
+ } finally {
+ setIsRefreshing(false);
+ setPullDistance(0);
+ }
+ } else {
+ // Threshold not reached - animate back to 0
+ setPullDistance(0);
+ }
+ },
+ [enabled, isRefreshing, pullDistance, threshold]
+ );
+
+ // Calculate progress (0 to 1)
+ const progress = Math.min(pullDistance / threshold, 1);
+ const isThresholdReached = pullDistance >= threshold;
+
+ return {
+ pullDistance,
+ isThresholdReached,
+ isRefreshing,
+ progress,
+ containerProps: {
+ onTouchStart: handleTouchStart,
+ onTouchMove: handleTouchMove,
+ onTouchEnd: handleTouchEnd,
+ },
+ };
+}
+
+export default usePullToRefresh;
diff --git a/src/web/hooks/useSessions.ts b/src/web/hooks/useSessions.ts
new file mode 100644
index 000000000..7aaed02b3
--- /dev/null
+++ b/src/web/hooks/useSessions.ts
@@ -0,0 +1,520 @@
+/**
+ * useSessions hook for Maestro web interface
+ *
+ * Provides real-time session state management for the web interface.
+ * Uses the WebSocket connection to receive session updates and provides
+ * methods to interact with sessions (send commands, interrupt, etc.).
+ */
+
+import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
+import {
+ useWebSocket,
+ type SessionData,
+ type UseWebSocketOptions,
+ type UseWebSocketReturn,
+ type WebSocketState,
+ type UsageStats,
+ type LastResponsePreview,
+} from './useWebSocket';
+
+// Re-export types for components
+export type { UsageStats, LastResponsePreview };
+import type { Theme } from '../../shared/theme-types';
+
+/**
+ * Extended session data with client-side state
+ */
+export interface Session extends SessionData {
+ /** Whether commands are currently being sent to this session */
+ isSending?: boolean;
+ /** Last error for this session */
+ lastError?: string;
+}
+
+/**
+ * Session state type (matches the desktop app's session states)
+ * - idle: Ready/Green
+ * - busy: Agent thinking/Yellow
+ * - error: No connection/Red
+ * - connecting: Pulsing Orange
+ */
+export type SessionState = 'idle' | 'busy' | 'error' | 'connecting';
+
+/**
+ * Input mode type
+ * - ai: AI mode (interacting with AI agents)
+ * - terminal: Command terminal mode
+ */
+export type InputMode = 'ai' | 'terminal';
+
+/**
+ * Options for the useSessions hook
+ */
+export interface UseSessionsOptions extends Omit {
+ /** Whether to automatically connect on mount */
+ autoConnect?: boolean;
+ /** Called when theme updates from server */
+ onThemeUpdate?: (theme: Theme) => void;
+ /** Called when sessions list changes */
+ onSessionsChange?: (sessions: Session[]) => void;
+ /** Called when active session changes */
+ onActiveSessionChange?: (session: Session | null) => void;
+ /** Called when an error occurs */
+ onError?: (error: string) => void;
+}
+
+/**
+ * Return type for the useSessions hook
+ */
+/**
+ * Group info containing group metadata and sessions
+ */
+export interface GroupInfo {
+ id: string | null;
+ name: string;
+ emoji: string | null;
+ sessions: Session[];
+}
+
+export interface UseSessionsReturn {
+ /** All sessions */
+ sessions: Session[];
+ /** Sessions organized by group (keyed by groupId or 'ungrouped') */
+ sessionsByGroup: Record;
+ /** Currently active/selected session */
+ activeSession: Session | null;
+ /** Set the active session by ID */
+ setActiveSessionId: (sessionId: string | null) => void;
+ /** Get a session by ID */
+ getSession: (sessionId: string) => Session | undefined;
+
+ /** WebSocket connection state */
+ connectionState: WebSocketState;
+ /** Whether connected and authenticated */
+ isConnected: boolean;
+ /** Connection error message */
+ connectionError: string | null;
+ /** Client ID assigned by server */
+ clientId: string | null;
+
+ /** Connect to the server */
+ connect: () => void;
+ /** Disconnect from the server */
+ disconnect: () => void;
+ /** Authenticate with a token */
+ authenticate: (token: string) => void;
+
+ /** Send a command to a session */
+ sendCommand: (sessionId: string, command: string) => Promise;
+ /** Send a command to the active session */
+ sendToActive: (command: string) => Promise;
+ /** Interrupt a session */
+ interrupt: (sessionId: string) => Promise;
+ /** Interrupt the active session */
+ interruptActive: () => Promise;
+ /** Switch session mode (AI/Terminal) */
+ switchMode: (sessionId: string, mode: InputMode) => Promise;
+
+ /** Refresh the sessions list from the server */
+ refreshSessions: () => void;
+
+ /** The underlying WebSocket hook return (for advanced use) */
+ ws: UseWebSocketReturn;
+}
+
+/**
+ * useSessions hook for managing sessions in the Maestro web interface
+ *
+ * @example
+ * ```tsx
+ * function App() {
+ * const {
+ * sessions,
+ * activeSession,
+ * setActiveSessionId,
+ * sendToActive,
+ * isConnected,
+ * connect,
+ * } = useSessions({
+ * autoConnect: true,
+ * onThemeUpdate: (theme) => setTheme(theme),
+ * });
+ *
+ * if (!isConnected) {
+ * return ;
+ * }
+ *
+ * return (
+ *
+ *
+ * sendToActive(cmd)}
+ * disabled={!activeSession}
+ * />
+ *
+ * );
+ * }
+ * ```
+ */
+export function useSessions(options: UseSessionsOptions = {}): UseSessionsReturn {
+ const {
+ autoConnect = false,
+ onThemeUpdate,
+ onSessionsChange,
+ onActiveSessionChange,
+ onError,
+ ...wsOptions
+ } = options;
+
+ // State
+ const [sessions, setSessions] = useState([]);
+ const [activeSessionId, setActiveSessionIdState] = useState(null);
+
+ // Refs for callbacks to avoid stale closures
+ const onThemeUpdateRef = useRef(onThemeUpdate);
+ const onSessionsChangeRef = useRef(onSessionsChange);
+ const onActiveSessionChangeRef = useRef(onActiveSessionChange);
+ const onErrorRef = useRef(onError);
+
+ useEffect(() => {
+ onThemeUpdateRef.current = onThemeUpdate;
+ onSessionsChangeRef.current = onSessionsChange;
+ onActiveSessionChangeRef.current = onActiveSessionChange;
+ onErrorRef.current = onError;
+ }, [onThemeUpdate, onSessionsChange, onActiveSessionChange, onError]);
+
+ /**
+ * Handle full sessions list update
+ */
+ const handleSessionsUpdate = useCallback((newSessions: SessionData[]) => {
+ setSessions((prev) => {
+ // Preserve client-side state (isSending, lastError) from previous sessions
+ const sessionsMap = new Map(prev.map((s) => [s.id, s]));
+ const updated = newSessions.map((session) => {
+ const existing = sessionsMap.get(session.id);
+ return {
+ ...session,
+ isSending: existing?.isSending,
+ lastError: existing?.lastError,
+ };
+ });
+ return updated;
+ });
+ onSessionsChangeRef.current?.(sessions);
+ }, [sessions]);
+
+ /**
+ * Handle individual session state change
+ */
+ const handleSessionStateChange = useCallback(
+ (sessionId: string, state: string, additionalData?: Partial) => {
+ setSessions((prev) => {
+ const index = prev.findIndex((s) => s.id === sessionId);
+ if (index === -1) return prev;
+
+ const updated = [...prev];
+ updated[index] = {
+ ...updated[index],
+ state,
+ ...additionalData,
+ };
+ return updated;
+ });
+ },
+ []
+ );
+
+ /**
+ * Handle session added
+ */
+ const handleSessionAdded = useCallback((session: SessionData) => {
+ setSessions((prev) => {
+ // Check if session already exists
+ if (prev.some((s) => s.id === session.id)) {
+ return prev;
+ }
+ return [...prev, session];
+ });
+ }, []);
+
+ /**
+ * Handle session removed
+ */
+ const handleSessionRemoved = useCallback((sessionId: string) => {
+ setSessions((prev) => prev.filter((s) => s.id !== sessionId));
+
+ // If the removed session was active, clear the active session
+ setActiveSessionIdState((currentActive) =>
+ currentActive === sessionId ? null : currentActive
+ );
+ }, []);
+
+ /**
+ * Handle theme update from server
+ */
+ const handleThemeUpdate = useCallback((theme: Theme) => {
+ onThemeUpdateRef.current?.(theme);
+ }, []);
+
+ /**
+ * Handle errors
+ */
+ const handleError = useCallback((error: string) => {
+ onErrorRef.current?.(error);
+ }, []);
+
+ // Initialize WebSocket with handlers
+ const ws = useWebSocket({
+ ...wsOptions,
+ handlers: {
+ onSessionsUpdate: handleSessionsUpdate,
+ onSessionStateChange: handleSessionStateChange,
+ onSessionAdded: handleSessionAdded,
+ onSessionRemoved: handleSessionRemoved,
+ onThemeUpdate: handleThemeUpdate,
+ onError: handleError,
+ },
+ });
+
+ // Auto-connect on mount if enabled
+ useEffect(() => {
+ if (autoConnect && ws.state === 'disconnected') {
+ ws.connect();
+ }
+ }, [autoConnect, ws.state, ws.connect]);
+
+ /**
+ * Get the active session object
+ */
+ const activeSession = useMemo(() => {
+ if (!activeSessionId) return null;
+ return sessions.find((s) => s.id === activeSessionId) ?? null;
+ }, [sessions, activeSessionId]);
+
+ /**
+ * Notify when active session changes
+ */
+ useEffect(() => {
+ onActiveSessionChangeRef.current?.(activeSession);
+ }, [activeSession]);
+
+ /**
+ * Set the active session by ID
+ */
+ const setActiveSessionId = useCallback((sessionId: string | null) => {
+ setActiveSessionIdState(sessionId);
+ }, []);
+
+ /**
+ * Get a session by ID
+ */
+ const getSession = useCallback(
+ (sessionId: string): Session | undefined => {
+ return sessions.find((s) => s.id === sessionId);
+ },
+ [sessions]
+ );
+
+ /**
+ * Sessions organized by group (using actual group data from server)
+ * Groups are keyed by groupId (or 'ungrouped' for sessions without a group)
+ */
+ const sessionsByGroup = useMemo((): Record => {
+ const groups: Record = {};
+
+ for (const session of sessions) {
+ const groupKey = session.groupId || 'ungrouped';
+
+ if (!groups[groupKey]) {
+ groups[groupKey] = {
+ id: session.groupId || null,
+ name: session.groupName || 'Ungrouped',
+ emoji: session.groupEmoji || null,
+ sessions: [],
+ };
+ }
+ groups[groupKey].sessions.push(session);
+ }
+
+ return groups;
+ }, [sessions]);
+
+ /**
+ * Get the base URL for API requests
+ */
+ const getApiBaseUrl = useCallback((): string => {
+ return `${window.location.protocol}//${window.location.host}`;
+ }, []);
+
+ /**
+ * Send a command to a session
+ */
+ const sendCommand = useCallback(
+ async (sessionId: string, command: string): Promise => {
+ // Mark session as sending
+ setSessions((prev) => {
+ const index = prev.findIndex((s) => s.id === sessionId);
+ if (index === -1) return prev;
+ const updated = [...prev];
+ updated[index] = { ...updated[index], isSending: true, lastError: undefined };
+ return updated;
+ });
+
+ try {
+ const response = await fetch(`${getApiBaseUrl()}/api/session/${sessionId}/send`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ command }),
+ });
+
+ const result = await response.json();
+
+ if (!response.ok || !result.success) {
+ throw new Error(result.error || 'Failed to send command');
+ }
+
+ // Clear sending state on success
+ setSessions((prev) => {
+ const index = prev.findIndex((s) => s.id === sessionId);
+ if (index === -1) return prev;
+ const updated = [...prev];
+ updated[index] = { ...updated[index], isSending: false };
+ return updated;
+ });
+
+ return true;
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+
+ // Set error state
+ setSessions((prev) => {
+ const index = prev.findIndex((s) => s.id === sessionId);
+ if (index === -1) return prev;
+ const updated = [...prev];
+ updated[index] = { ...updated[index], isSending: false, lastError: errorMessage };
+ return updated;
+ });
+
+ onErrorRef.current?.(errorMessage);
+ return false;
+ }
+ },
+ [getApiBaseUrl]
+ );
+
+ /**
+ * Send a command to the active session
+ */
+ const sendToActive = useCallback(
+ async (command: string): Promise => {
+ if (!activeSessionId) {
+ onErrorRef.current?.('No active session');
+ return false;
+ }
+ return sendCommand(activeSessionId, command);
+ },
+ [activeSessionId, sendCommand]
+ );
+
+ /**
+ * Interrupt a session
+ */
+ const interrupt = useCallback(
+ async (sessionId: string): Promise => {
+ try {
+ const response = await fetch(`${getApiBaseUrl()}/api/session/${sessionId}/interrupt`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ const result = await response.json();
+
+ if (!response.ok || !result.success) {
+ throw new Error(result.error || 'Failed to interrupt session');
+ }
+
+ return true;
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ onErrorRef.current?.(errorMessage);
+ return false;
+ }
+ },
+ [getApiBaseUrl]
+ );
+
+ /**
+ * Interrupt the active session
+ */
+ const interruptActive = useCallback(async (): Promise => {
+ if (!activeSessionId) {
+ onErrorRef.current?.('No active session');
+ return false;
+ }
+ return interrupt(activeSessionId);
+ }, [activeSessionId, interrupt]);
+
+ /**
+ * Switch session mode (AI/Terminal)
+ */
+ const switchMode = useCallback(
+ async (sessionId: string, mode: InputMode): Promise => {
+ // This would typically be sent via WebSocket or API
+ // For now, we send it as a message via WebSocket
+ return ws.send({
+ type: 'switch_mode',
+ sessionId,
+ mode,
+ });
+ },
+ [ws]
+ );
+
+ /**
+ * Refresh the sessions list
+ */
+ const refreshSessions = useCallback(() => {
+ ws.send({ type: 'get_sessions' });
+ }, [ws]);
+
+ return {
+ // Session data
+ sessions,
+ sessionsByGroup,
+ activeSession,
+ setActiveSessionId,
+ getSession,
+
+ // Connection state
+ connectionState: ws.state,
+ isConnected: ws.isAuthenticated,
+ connectionError: ws.error,
+ clientId: ws.clientId,
+
+ // Connection methods
+ connect: ws.connect,
+ disconnect: ws.disconnect,
+ authenticate: ws.authenticate,
+
+ // Session interaction methods
+ sendCommand,
+ sendToActive,
+ interrupt,
+ interruptActive,
+ switchMode,
+ refreshSessions,
+
+ // Underlying WebSocket hook
+ ws,
+ };
+}
+
+export default useSessions;
diff --git a/src/web/hooks/useSwipeGestures.ts b/src/web/hooks/useSwipeGestures.ts
new file mode 100644
index 000000000..da04be61f
--- /dev/null
+++ b/src/web/hooks/useSwipeGestures.ts
@@ -0,0 +1,380 @@
+/**
+ * useSwipeGestures hook for Maestro mobile web interface
+ *
+ * A comprehensive swipe gesture detection hook that supports
+ * horizontal and vertical swipe gestures with configurable thresholds.
+ *
+ * Common actions:
+ * - Swipe left: Delete item, dismiss action
+ * - Swipe right: Archive, mark as read, reveal actions
+ * - Swipe up: Open drawer, show more options
+ * - Swipe down: Dismiss modal, close panel
+ *
+ * Features:
+ * - Detects swipe direction (left, right, up, down)
+ * - Configurable distance and velocity thresholds
+ * - Support for revealing action buttons (like iOS swipe-to-delete)
+ * - Visual offset tracking for animations
+ * - Haptic feedback triggers
+ */
+
+import { useCallback, useRef, useState } from 'react';
+// Import from constants directly to avoid circular dependency with mobile/index.tsx
+import { GESTURE_THRESHOLDS } from '../mobile/constants';
+
+/**
+ * Swipe direction enum
+ */
+export type SwipeDirection = 'left' | 'right' | 'up' | 'down' | null;
+
+/**
+ * Configuration options for swipe gesture detection
+ */
+export interface UseSwipeGesturesOptions {
+ /** Callback when swipe left is detected */
+ onSwipeLeft?: () => void;
+ /** Callback when swipe right is detected */
+ onSwipeRight?: () => void;
+ /** Callback when swipe up is detected */
+ onSwipeUp?: () => void;
+ /** Callback when swipe down is detected */
+ onSwipeDown?: () => void;
+ /** Minimum distance to trigger swipe (default: 50px) */
+ threshold?: number;
+ /** Maximum time for swipe gesture in ms (default: 300ms) */
+ maxTime?: number;
+ /** Whether swipe detection is enabled (default: true) */
+ enabled?: boolean;
+ /** Whether to track offset for animations (enables drag feedback) */
+ trackOffset?: boolean;
+ /** Maximum offset when tracking (default: 100px) - for elastic effect */
+ maxOffset?: number;
+ /** Horizontal resistance factor when dragging (0-1, lower = more resistance) */
+ resistanceFactor?: number;
+ /** Velocity threshold for quick flick gestures (px/ms) */
+ velocityThreshold?: number;
+ /** Lock to a single direction once determined */
+ lockDirection?: boolean;
+}
+
+/**
+ * Return type for useSwipeGestures hook
+ */
+export interface UseSwipeGesturesReturn {
+ /** Props to spread on the target element */
+ handlers: {
+ onTouchStart: (e: React.TouchEvent) => void;
+ onTouchMove: (e: React.TouchEvent) => void;
+ onTouchEnd: (e: React.TouchEvent) => void;
+ onTouchCancel: (e: React.TouchEvent) => void;
+ };
+ /** Current horizontal swipe offset in pixels (for animations) */
+ offsetX: number;
+ /** Current vertical swipe offset in pixels (for animations) */
+ offsetY: number;
+ /** Whether currently swiping */
+ isSwiping: boolean;
+ /** Current detected swipe direction (during gesture) */
+ swipeDirection: SwipeDirection;
+ /** Reset the offset state (useful after action completes) */
+ resetOffset: () => void;
+}
+
+/**
+ * Custom hook for detecting multi-directional swipe gestures
+ *
+ * @example
+ * ```tsx
+ * // Basic swipe detection
+ * function SwipeableItem({ onDelete }) {
+ * const { handlers, offsetX } = useSwipeGestures({
+ * onSwipeLeft: () => onDelete(),
+ * threshold: 80,
+ * });
+ *
+ * return (
+ *
+ * Item content
+ *
+ * );
+ * }
+ * ```
+ *
+ * @example
+ * ```tsx
+ * // Swipe with action reveal
+ * function SwipeToDeleteItem({ item, onDelete }) {
+ * const { handlers, offsetX, isSwiping, resetOffset } = useSwipeGestures({
+ * onSwipeLeft: () => {
+ * onDelete(item.id);
+ * resetOffset();
+ * },
+ * trackOffset: true,
+ * maxOffset: 100,
+ * });
+ *
+ * const showDeleteButton = offsetX < -50;
+ *
+ * return (
+ *
+ * {showDeleteButton && (
+ *
Delete
+ * )}
+ *
+ * {item.content}
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export function useSwipeGestures(options: UseSwipeGesturesOptions = {}): UseSwipeGesturesReturn {
+ const {
+ onSwipeLeft,
+ onSwipeRight,
+ onSwipeUp,
+ onSwipeDown,
+ threshold = GESTURE_THRESHOLDS.swipeDistance,
+ maxTime = GESTURE_THRESHOLDS.swipeTime,
+ enabled = true,
+ trackOffset = false,
+ maxOffset = 100,
+ resistanceFactor = 0.5,
+ velocityThreshold = 0.5,
+ lockDirection = true,
+ } = options;
+
+ // Touch state tracking
+ const touchStartX = useRef(0);
+ const touchStartY = useRef(0);
+ const touchStartTime = useRef(0);
+ const isTracking = useRef(false);
+ const lockedDirection = useRef<'horizontal' | 'vertical' | null>(null);
+
+ // Visual feedback state
+ const [offsetX, setOffsetX] = useState(0);
+ const [offsetY, setOffsetY] = useState(0);
+ const [isSwiping, setIsSwiping] = useState(false);
+ const [swipeDirection, setSwipeDirection] = useState(null);
+
+ /**
+ * Reset offset state
+ */
+ const resetOffset = useCallback(() => {
+ setOffsetX(0);
+ setOffsetY(0);
+ setIsSwiping(false);
+ setSwipeDirection(null);
+ lockedDirection.current = null;
+ }, []);
+
+ /**
+ * Apply resistance to offset (diminishing returns as you drag further)
+ */
+ const applyResistance = useCallback(
+ (delta: number, max: number): number => {
+ const sign = delta >= 0 ? 1 : -1;
+ const absDelta = Math.abs(delta);
+ // Apply resistance using asymptotic curve
+ const resisted = max * (1 - Math.exp(-absDelta * resistanceFactor / max));
+ return sign * Math.min(resisted, max);
+ },
+ [resistanceFactor]
+ );
+
+ /**
+ * Handle touch start
+ */
+ const handleTouchStart = useCallback(
+ (e: React.TouchEvent) => {
+ if (!enabled) return;
+
+ const touch = e.touches[0];
+ touchStartX.current = touch.clientX;
+ touchStartY.current = touch.clientY;
+ touchStartTime.current = Date.now();
+ isTracking.current = true;
+ lockedDirection.current = null;
+ setIsSwiping(true);
+ setSwipeDirection(null);
+ },
+ [enabled]
+ );
+
+ /**
+ * Handle touch move - track movement and update offset
+ */
+ const handleTouchMove = useCallback(
+ (e: React.TouchEvent) => {
+ if (!enabled || !isTracking.current) return;
+
+ const touch = e.touches[0];
+ const deltaX = touch.clientX - touchStartX.current;
+ const deltaY = touch.clientY - touchStartY.current;
+ const absDeltaX = Math.abs(deltaX);
+ const absDeltaY = Math.abs(deltaY);
+
+ // Determine and lock direction if not already locked
+ if (lockDirection && !lockedDirection.current && (absDeltaX > 10 || absDeltaY > 10)) {
+ lockedDirection.current = absDeltaX > absDeltaY ? 'horizontal' : 'vertical';
+ }
+
+ // Determine current swipe direction
+ let currentDirection: SwipeDirection = null;
+ if (absDeltaX > absDeltaY) {
+ currentDirection = deltaX < 0 ? 'left' : 'right';
+ } else {
+ currentDirection = deltaY < 0 ? 'up' : 'down';
+ }
+ setSwipeDirection(currentDirection);
+
+ // Update offsets if tracking is enabled
+ if (trackOffset) {
+ // Apply direction lock if enabled
+ if (lockDirection && lockedDirection.current === 'horizontal') {
+ // Only allow left swipe if handler exists, otherwise allow right
+ const allowLeft = onSwipeLeft !== undefined;
+ const allowRight = onSwipeRight !== undefined;
+
+ let adjustedX = deltaX;
+ if (deltaX < 0 && !allowLeft) {
+ adjustedX = 0;
+ } else if (deltaX > 0 && !allowRight) {
+ adjustedX = 0;
+ }
+
+ setOffsetX(applyResistance(adjustedX, maxOffset));
+ setOffsetY(0);
+ } else if (lockDirection && lockedDirection.current === 'vertical') {
+ const allowUp = onSwipeUp !== undefined;
+ const allowDown = onSwipeDown !== undefined;
+
+ let adjustedY = deltaY;
+ if (deltaY < 0 && !allowUp) {
+ adjustedY = 0;
+ } else if (deltaY > 0 && !allowDown) {
+ adjustedY = 0;
+ }
+
+ setOffsetX(0);
+ setOffsetY(applyResistance(adjustedY, maxOffset));
+ } else if (!lockDirection) {
+ setOffsetX(applyResistance(deltaX, maxOffset));
+ setOffsetY(applyResistance(deltaY, maxOffset));
+ }
+ }
+
+ // Prevent scrolling if we've locked to horizontal swipe
+ if (lockDirection && lockedDirection.current === 'horizontal' && absDeltaX > 10) {
+ e.preventDefault();
+ }
+ },
+ [enabled, trackOffset, maxOffset, applyResistance, lockDirection, onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown]
+ );
+
+ /**
+ * Handle touch end - determine if swipe criteria met
+ */
+ const handleTouchEnd = useCallback(
+ (e: React.TouchEvent) => {
+ if (!enabled || !isTracking.current) {
+ resetOffset();
+ return;
+ }
+
+ isTracking.current = false;
+
+ const touch = e.changedTouches[0];
+ const deltaX = touch.clientX - touchStartX.current;
+ const deltaY = touch.clientY - touchStartY.current;
+ const absDeltaX = Math.abs(deltaX);
+ const absDeltaY = Math.abs(deltaY);
+ const duration = Date.now() - touchStartTime.current;
+
+ // Calculate velocity (px/ms)
+ const velocityX = absDeltaX / duration;
+ const velocityY = absDeltaY / duration;
+
+ // Check for valid swipe gesture
+ const isQuickSwipe = duration < maxTime;
+ const isHighVelocity = Math.max(velocityX, velocityY) > velocityThreshold;
+ const meetsThreshold = absDeltaX > threshold || absDeltaY > threshold;
+
+ // Only trigger if it's a quick swipe or high velocity, and meets threshold
+ if ((isQuickSwipe || isHighVelocity) && meetsThreshold) {
+ // Determine direction and trigger callback
+ if (absDeltaX > absDeltaY) {
+ // Horizontal swipe
+ if (deltaX < 0 && onSwipeLeft) {
+ onSwipeLeft();
+ } else if (deltaX > 0 && onSwipeRight) {
+ onSwipeRight();
+ }
+ } else {
+ // Vertical swipe
+ if (deltaY < 0 && onSwipeUp) {
+ onSwipeUp();
+ } else if (deltaY > 0 && onSwipeDown) {
+ onSwipeDown();
+ }
+ }
+ }
+
+ // Reset offset state (with animation via CSS transition)
+ setIsSwiping(false);
+ setSwipeDirection(null);
+
+ // Don't reset offset immediately if trackOffset is enabled
+ // This allows the consumer to animate back or take action first
+ if (!trackOffset) {
+ setOffsetX(0);
+ setOffsetY(0);
+ } else {
+ // Auto-reset after a short delay if no action taken
+ setTimeout(() => {
+ setOffsetX(0);
+ setOffsetY(0);
+ }, 50);
+ }
+
+ lockedDirection.current = null;
+ },
+ [enabled, threshold, maxTime, velocityThreshold, onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, trackOffset, resetOffset]
+ );
+
+ /**
+ * Handle touch cancel
+ */
+ const handleTouchCancel = useCallback(
+ () => {
+ resetOffset();
+ isTracking.current = false;
+ },
+ [resetOffset]
+ );
+
+ return {
+ handlers: {
+ onTouchStart: handleTouchStart,
+ onTouchMove: handleTouchMove,
+ onTouchEnd: handleTouchEnd,
+ onTouchCancel: handleTouchCancel,
+ },
+ offsetX,
+ offsetY,
+ isSwiping,
+ swipeDirection,
+ resetOffset,
+ };
+}
+
+export default useSwipeGestures;
diff --git a/src/web/hooks/useSwipeUp.ts b/src/web/hooks/useSwipeUp.ts
new file mode 100644
index 000000000..cef74905e
--- /dev/null
+++ b/src/web/hooks/useSwipeUp.ts
@@ -0,0 +1,140 @@
+/**
+ * useSwipeUp hook for Maestro mobile web interface
+ *
+ * Detects upward swipe gestures for triggering actions like opening drawers.
+ * Used primarily for the command history drawer swipe-up interaction.
+ */
+
+import { useCallback, useRef } from 'react';
+// Import from constants directly to avoid circular dependency with mobile/index.tsx
+import { GESTURE_THRESHOLDS } from '../mobile/constants';
+
+export interface UseSwipeUpOptions {
+ /** Called when swipe up is detected */
+ onSwipeUp: () => void;
+ /** Minimum distance to trigger swipe (default: 50px) */
+ threshold?: number;
+ /** Maximum time for swipe gesture (default: 300ms) */
+ maxTime?: number;
+ /** Whether swipe detection is enabled (default: true) */
+ enabled?: boolean;
+}
+
+export interface UseSwipeUpReturn {
+ /** Props to spread on the target element */
+ handlers: {
+ onTouchStart: (e: React.TouchEvent) => void;
+ onTouchMove: (e: React.TouchEvent) => void;
+ onTouchEnd: (e: React.TouchEvent) => void;
+ };
+}
+
+/**
+ * Custom hook for detecting upward swipe gestures
+ *
+ * @example
+ * ```tsx
+ * function InputBar() {
+ * const { handlers } = useSwipeUp({
+ * onSwipeUp: () => setHistoryOpen(true),
+ * });
+ *
+ * return (
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export function useSwipeUp(options: UseSwipeUpOptions): UseSwipeUpReturn {
+ const {
+ onSwipeUp,
+ threshold = GESTURE_THRESHOLDS.swipeDistance,
+ maxTime = GESTURE_THRESHOLDS.swipeTime,
+ enabled = true,
+ } = options;
+
+ // Track touch state
+ const touchStartY = useRef(0);
+ const touchStartX = useRef(0);
+ const touchStartTime = useRef(0);
+ const isTracking = useRef(false);
+
+ /**
+ * Handle touch start
+ */
+ const handleTouchStart = useCallback(
+ (e: React.TouchEvent) => {
+ if (!enabled) return;
+
+ const touch = e.touches[0];
+ touchStartY.current = touch.clientY;
+ touchStartX.current = touch.clientX;
+ touchStartTime.current = Date.now();
+ isTracking.current = true;
+ },
+ [enabled]
+ );
+
+ /**
+ * Handle touch move - track if we should continue detecting
+ */
+ const handleTouchMove = useCallback(
+ (e: React.TouchEvent) => {
+ if (!enabled || !isTracking.current) return;
+
+ const touch = e.touches[0];
+ const deltaX = Math.abs(touch.clientX - touchStartX.current);
+ const deltaY = touchStartY.current - touch.clientY; // Positive = up
+
+ // Cancel tracking if horizontal movement exceeds vertical (scrolling)
+ if (deltaX > Math.abs(deltaY)) {
+ isTracking.current = false;
+ }
+ },
+ [enabled]
+ );
+
+ /**
+ * Handle touch end - check if swipe up criteria met
+ */
+ const handleTouchEnd = useCallback(
+ (e: React.TouchEvent) => {
+ if (!enabled || !isTracking.current) {
+ isTracking.current = false;
+ return;
+ }
+
+ isTracking.current = false;
+
+ const touch = e.changedTouches[0];
+ const deltaY = touchStartY.current - touch.clientY; // Positive = up
+ const deltaX = Math.abs(touch.clientX - touchStartX.current);
+ const duration = Date.now() - touchStartTime.current;
+
+ // Check if this is a valid swipe up:
+ // 1. Moved up more than threshold
+ // 2. Completed within max time
+ // 3. More vertical than horizontal
+ if (
+ deltaY > threshold &&
+ duration < maxTime &&
+ deltaY > deltaX
+ ) {
+ onSwipeUp();
+ }
+ },
+ [enabled, threshold, maxTime, onSwipeUp]
+ );
+
+ return {
+ handlers: {
+ onTouchStart: handleTouchStart,
+ onTouchMove: handleTouchMove,
+ onTouchEnd: handleTouchEnd,
+ },
+ };
+}
+
+export default useSwipeUp;
diff --git a/src/web/hooks/useUnreadBadge.ts b/src/web/hooks/useUnreadBadge.ts
new file mode 100644
index 000000000..17854dc7b
--- /dev/null
+++ b/src/web/hooks/useUnreadBadge.ts
@@ -0,0 +1,215 @@
+/**
+ * Unread Badge Hook for Maestro Mobile Web
+ *
+ * Manages unread response counts and updates the app badge
+ * using the Navigator Badge API (PWA feature).
+ *
+ * Features:
+ * - Track unread response count
+ * - Update app badge on home screen
+ * - Persist unread state to localStorage
+ * - Clear badge when user views responses
+ */
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { webLogger } from '../utils/logger';
+
+/**
+ * Storage key for persisting unread response IDs
+ */
+const UNREAD_RESPONSES_KEY = 'maestro_unread_responses';
+
+/**
+ * Configuration options for the useUnreadBadge hook
+ */
+export interface UseUnreadBadgeOptions {
+ /** Callback when unread count changes */
+ onCountChange?: (count: number) => void;
+ /** Whether to auto-clear badge when app becomes visible (default: true) */
+ autoClearOnVisible?: boolean;
+}
+
+/**
+ * Return type for the useUnreadBadge hook
+ */
+export interface UseUnreadBadgeReturn {
+ /** Current unread response count */
+ unreadCount: number;
+ /** Set of unread response IDs */
+ unreadIds: Set;
+ /** Whether the Badge API is supported */
+ isSupported: boolean;
+ /** Add an unread response (increments badge) */
+ addUnread: (responseId: string) => void;
+ /** Mark a response as read */
+ markRead: (responseId: string) => void;
+ /** Mark all responses as read (clears badge) */
+ markAllRead: () => void;
+ /** Set the badge count directly */
+ setBadgeCount: (count: number) => void;
+ /** Clear the badge */
+ clearBadge: () => void;
+}
+
+/**
+ * Check if the Badge API is supported
+ */
+export function isBadgeApiSupported(): boolean {
+ return typeof navigator !== 'undefined' && 'setAppBadge' in navigator;
+}
+
+/**
+ * Load unread IDs from localStorage
+ */
+function loadUnreadIds(): Set {
+ if (typeof localStorage === 'undefined') return new Set();
+ try {
+ const stored = localStorage.getItem(UNREAD_RESPONSES_KEY);
+ if (stored) {
+ const ids = JSON.parse(stored);
+ if (Array.isArray(ids)) {
+ return new Set(ids);
+ }
+ }
+ } catch (error) {
+ webLogger.error('Error loading unread IDs', 'UnreadBadge', error);
+ }
+ return new Set();
+}
+
+/**
+ * Save unread IDs to localStorage
+ */
+function saveUnreadIds(ids: Set): void {
+ if (typeof localStorage === 'undefined') return;
+ try {
+ localStorage.setItem(UNREAD_RESPONSES_KEY, JSON.stringify([...ids]));
+ } catch (error) {
+ webLogger.error('Error saving unread IDs', 'UnreadBadge', error);
+ }
+}
+
+/**
+ * Hook for managing unread response badge count
+ *
+ * @param options - Configuration options
+ * @returns Unread badge state and control functions
+ */
+export function useUnreadBadge(
+ options: UseUnreadBadgeOptions = {}
+): UseUnreadBadgeReturn {
+ const { onCountChange, autoClearOnVisible = true } = options;
+
+ const isSupported = isBadgeApiSupported();
+ const [unreadIds, setUnreadIds] = useState>(() => loadUnreadIds());
+ const onCountChangeRef = useRef(onCountChange);
+ onCountChangeRef.current = onCountChange;
+
+ // Computed unread count
+ const unreadCount = unreadIds.size;
+
+ /**
+ * Update the app badge
+ */
+ const updateBadge = useCallback(async (count: number) => {
+ if (!isSupported) return;
+
+ try {
+ if (count > 0) {
+ await navigator.setAppBadge(count);
+ webLogger.debug(`Badge set to: ${count}`, 'UnreadBadge');
+ } else {
+ await navigator.clearAppBadge();
+ webLogger.debug('Badge cleared', 'UnreadBadge');
+ }
+ } catch (error) {
+ // Badge API may fail if not running as PWA
+ webLogger.debug('Badge API unavailable', 'UnreadBadge', error);
+ }
+ }, [isSupported]);
+
+ /**
+ * Add an unread response
+ */
+ const addUnread = useCallback((responseId: string) => {
+ setUnreadIds((prev) => {
+ if (prev.has(responseId)) return prev;
+ const next = new Set(prev);
+ next.add(responseId);
+ saveUnreadIds(next);
+ return next;
+ });
+ }, []);
+
+ /**
+ * Mark a response as read
+ */
+ const markRead = useCallback((responseId: string) => {
+ setUnreadIds((prev) => {
+ if (!prev.has(responseId)) return prev;
+ const next = new Set(prev);
+ next.delete(responseId);
+ saveUnreadIds(next);
+ return next;
+ });
+ }, []);
+
+ /**
+ * Mark all responses as read
+ */
+ const markAllRead = useCallback(() => {
+ setUnreadIds(() => {
+ const next = new Set();
+ saveUnreadIds(next);
+ return next;
+ });
+ }, []);
+
+ /**
+ * Set badge count directly
+ */
+ const setBadgeCount = useCallback(async (count: number) => {
+ await updateBadge(count);
+ }, [updateBadge]);
+
+ /**
+ * Clear the badge
+ */
+ const clearBadge = useCallback(async () => {
+ await updateBadge(0);
+ }, [updateBadge]);
+
+ // Update badge when unread count changes
+ useEffect(() => {
+ updateBadge(unreadCount);
+ onCountChangeRef.current?.(unreadCount);
+ }, [unreadCount, updateBadge]);
+
+ // Auto-clear badge when app becomes visible
+ useEffect(() => {
+ if (!autoClearOnVisible) return;
+
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'visible') {
+ // User is looking at the app, mark all as read
+ markAllRead();
+ }
+ };
+
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+ return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
+ }, [autoClearOnVisible, markAllRead]);
+
+ return {
+ unreadCount,
+ unreadIds,
+ isSupported,
+ addUnread,
+ markRead,
+ markAllRead,
+ setBadgeCount,
+ clearBadge,
+ };
+}
+
+export default useUnreadBadge;
diff --git a/src/web/hooks/useWebSocket.ts b/src/web/hooks/useWebSocket.ts
new file mode 100644
index 000000000..b47d63257
--- /dev/null
+++ b/src/web/hooks/useWebSocket.ts
@@ -0,0 +1,756 @@
+/**
+ * useWebSocket hook for Maestro web interface
+ *
+ * Provides WebSocket connection management for the web interface,
+ * handling connection, reconnection, and message handling.
+ *
+ * Note: Authentication is handled via URL path (security token in URL),
+ * so no separate auth handshake is needed.
+ */
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import type { Theme } from '../../shared/theme-types';
+import { buildWebSocketUrl as buildWsUrl, getCurrentSessionId } from '../utils/config';
+import { webLogger } from '../utils/logger';
+
+/**
+ * WebSocket connection states
+ */
+export type WebSocketState = 'disconnected' | 'connecting' | 'connected' | 'authenticating' | 'authenticated';
+
+/**
+ * Usage stats for session cost/token tracking
+ */
+export interface UsageStats {
+ inputTokens?: number;
+ outputTokens?: number;
+ cacheReadInputTokens?: number;
+ cacheCreationInputTokens?: number;
+ totalCostUsd?: number;
+ contextWindow?: number;
+}
+
+/**
+ * Last response preview for mobile display
+ * Contains a truncated version of the last AI response
+ */
+export interface LastResponsePreview {
+ text: string; // First 3 lines or ~500 chars of the last AI response
+ timestamp: number;
+ source: 'stdout' | 'stderr' | 'system';
+ fullLength: number; // Total length of the original response
+}
+
+/**
+ * Session data received from the server
+ */
+export interface SessionData {
+ id: string;
+ name: string;
+ toolType: string;
+ state: string;
+ inputMode: string;
+ cwd: string;
+ groupId?: string | null;
+ groupName?: string | null;
+ groupEmoji?: string | null;
+ usageStats?: UsageStats | null;
+ lastResponse?: LastResponsePreview | null;
+ claudeSessionId?: string | null;
+}
+
+/**
+ * Message types sent by the server
+ */
+export type ServerMessageType =
+ | 'connected'
+ | 'auth_required'
+ | 'auth_success'
+ | 'auth_failed'
+ | 'sessions_list'
+ | 'session_state_change'
+ | 'session_added'
+ | 'session_removed'
+ | 'active_session_changed'
+ | 'session_output'
+ | 'session_exit'
+ | 'theme'
+ | 'custom_commands'
+ | 'pong'
+ | 'subscribed'
+ | 'echo'
+ | 'error';
+
+/**
+ * Base server message structure
+ */
+export interface ServerMessage {
+ type: ServerMessageType;
+ timestamp?: number;
+ [key: string]: unknown;
+}
+
+/**
+ * Connected message from server
+ */
+export interface ConnectedMessage extends ServerMessage {
+ type: 'connected';
+ clientId: string;
+ message: string;
+ authenticated: boolean;
+}
+
+/**
+ * Auth required message from server
+ */
+export interface AuthRequiredMessage extends ServerMessage {
+ type: 'auth_required';
+ clientId: string;
+ message: string;
+}
+
+/**
+ * Auth success message from server
+ */
+export interface AuthSuccessMessage extends ServerMessage {
+ type: 'auth_success';
+ clientId: string;
+ message: string;
+}
+
+/**
+ * Auth failed message from server
+ */
+export interface AuthFailedMessage extends ServerMessage {
+ type: 'auth_failed';
+ message: string;
+}
+
+/**
+ * Sessions list message from server
+ */
+export interface SessionsListMessage extends ServerMessage {
+ type: 'sessions_list';
+ sessions: SessionData[];
+}
+
+/**
+ * Session state change message from server
+ */
+export interface SessionStateChangeMessage extends ServerMessage {
+ type: 'session_state_change';
+ sessionId: string;
+ state: string;
+ name?: string;
+ toolType?: string;
+ inputMode?: string;
+ cwd?: string;
+}
+
+/**
+ * Session added message from server
+ */
+export interface SessionAddedMessage extends ServerMessage {
+ type: 'session_added';
+ session: SessionData;
+}
+
+/**
+ * Session removed message from server
+ */
+export interface SessionRemovedMessage extends ServerMessage {
+ type: 'session_removed';
+ sessionId: string;
+}
+
+/**
+ * Active session changed message from server
+ * Sent when the desktop app switches to a different session
+ */
+export interface ActiveSessionChangedMessage extends ServerMessage {
+ type: 'active_session_changed';
+ sessionId: string;
+}
+
+/**
+ * Session output message from server (real-time AI/terminal output)
+ */
+export interface SessionOutputMessage extends ServerMessage {
+ type: 'session_output';
+ sessionId: string;
+ data: string;
+ source: 'ai' | 'terminal';
+}
+
+/**
+ * Session exit message from server (process completed)
+ */
+export interface SessionExitMessage extends ServerMessage {
+ type: 'session_exit';
+ sessionId: string;
+ exitCode: number;
+}
+
+/**
+ * User input message from server (message sent from desktop app)
+ */
+export interface UserInputMessage extends ServerMessage {
+ type: 'user_input';
+ sessionId: string;
+ command: string;
+ inputMode: 'ai' | 'terminal';
+}
+
+/**
+ * Theme message from server
+ */
+export interface ThemeMessage extends ServerMessage {
+ type: 'theme';
+ theme: Theme;
+}
+
+/**
+ * Custom AI command definition
+ */
+export interface CustomCommand {
+ id: string;
+ command: string;
+ description: string;
+ prompt: string;
+}
+
+/**
+ * Custom commands message from server
+ */
+export interface CustomCommandsMessage extends ServerMessage {
+ type: 'custom_commands';
+ commands: CustomCommand[];
+}
+
+/**
+ * Error message from server
+ */
+export interface ErrorMessage extends ServerMessage {
+ type: 'error';
+ message: string;
+}
+
+/**
+ * Union type of all possible server messages
+ */
+export type TypedServerMessage =
+ | ConnectedMessage
+ | AuthRequiredMessage
+ | AuthSuccessMessage
+ | AuthFailedMessage
+ | SessionsListMessage
+ | SessionStateChangeMessage
+ | SessionAddedMessage
+ | SessionRemovedMessage
+ | ActiveSessionChangedMessage
+ | SessionOutputMessage
+ | SessionExitMessage
+ | UserInputMessage
+ | ThemeMessage
+ | CustomCommandsMessage
+ | ErrorMessage
+ | ServerMessage;
+
+/**
+ * Event handlers for WebSocket events
+ */
+export interface WebSocketEventHandlers {
+ /** Called when sessions list is received or updated */
+ onSessionsUpdate?: (sessions: SessionData[]) => void;
+ /** Called when a single session state changes */
+ onSessionStateChange?: (sessionId: string, state: string, additionalData?: Partial) => void;
+ /** Called when a session is added */
+ onSessionAdded?: (session: SessionData) => void;
+ /** Called when a session is removed */
+ onSessionRemoved?: (sessionId: string) => void;
+ /** Called when the active session changes on the desktop */
+ onActiveSessionChanged?: (sessionId: string) => void;
+ /** Called when session output is received (real-time AI/terminal output) */
+ onSessionOutput?: (sessionId: string, data: string, source: 'ai' | 'terminal') => void;
+ /** Called when a session process exits */
+ onSessionExit?: (sessionId: string, exitCode: number) => void;
+ /** Called when user input is received (message sent from desktop app) */
+ onUserInput?: (sessionId: string, command: string, inputMode: 'ai' | 'terminal') => void;
+ /** Called when theme is received or updated */
+ onThemeUpdate?: (theme: Theme) => void;
+ /** Called when custom commands are received */
+ onCustomCommands?: (commands: CustomCommand[]) => void;
+ /** Called when connection state changes */
+ onConnectionChange?: (state: WebSocketState) => void;
+ /** Called when an error occurs */
+ onError?: (error: string) => void;
+ /** Called for any message (for debugging or custom handling) */
+ onMessage?: (message: TypedServerMessage) => void;
+}
+
+/**
+ * Configuration options for the WebSocket connection
+ */
+export interface UseWebSocketOptions {
+ /** WebSocket URL (defaults to /ws/web on current host) */
+ url?: string;
+ /** Authentication token (optional, can also be provided via URL query param) */
+ token?: string;
+ /** Whether to automatically reconnect on disconnection */
+ autoReconnect?: boolean;
+ /** Maximum number of reconnection attempts */
+ maxReconnectAttempts?: number;
+ /** Delay between reconnection attempts in milliseconds */
+ reconnectDelay?: number;
+ /** Ping interval in milliseconds (0 to disable) */
+ pingInterval?: number;
+ /** Event handlers */
+ handlers?: WebSocketEventHandlers;
+}
+
+/**
+ * Return value from useWebSocket hook
+ */
+export interface UseWebSocketReturn {
+ /** Current connection state */
+ state: WebSocketState;
+ /** Whether the connection is fully authenticated */
+ isAuthenticated: boolean;
+ /** Whether the connection is active (connected or authenticated) */
+ isConnected: boolean;
+ /** Client ID assigned by the server */
+ clientId: string | null;
+ /** Last error message */
+ error: string | null;
+ /** Number of reconnection attempts made */
+ reconnectAttempts: number;
+ /** Manually connect to the WebSocket server */
+ connect: () => void;
+ /** Manually disconnect from the WebSocket server */
+ disconnect: () => void;
+ /** Send an authentication token */
+ authenticate: (token: string) => void;
+ /** Send a ping message */
+ ping: () => void;
+ /** Send a raw message to the server */
+ send: (message: object) => boolean;
+}
+
+/**
+ * Default configuration values
+ */
+const DEFAULT_OPTIONS: Required> = {
+ url: '',
+ autoReconnect: true,
+ maxReconnectAttempts: 10,
+ reconnectDelay: 2000,
+ pingInterval: 30000,
+};
+
+/**
+ * Build the WebSocket URL using the config
+ * The security token is in the URL path, not as a query param
+ */
+function buildWebSocketUrl(baseUrl?: string, sessionId?: string): string {
+ if (baseUrl) {
+ return baseUrl;
+ }
+
+ // Use config to build the URL with security token in path
+ // If sessionId is provided, subscribe to that session's updates
+ return buildWsUrl(sessionId || getCurrentSessionId() || undefined);
+}
+
+/**
+ * useWebSocket hook for managing WebSocket connections to the Maestro server
+ *
+ * @example
+ * ```tsx
+ * function App() {
+ * const { state, isAuthenticated, connect, authenticate } = useWebSocket({
+ * handlers: {
+ * onSessionsUpdate: (sessions) => setSessions(sessions),
+ * onThemeUpdate: (theme) => setTheme(theme),
+ * },
+ * });
+ *
+ * if (state === 'disconnected') {
+ * return ;
+ * }
+ *
+ * if (!isAuthenticated) {
+ * return authenticate(token)} />;
+ * }
+ *
+ * return ;
+ * }
+ * ```
+ */
+export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketReturn {
+ const {
+ url: baseUrl,
+ token,
+ autoReconnect = DEFAULT_OPTIONS.autoReconnect,
+ maxReconnectAttempts = DEFAULT_OPTIONS.maxReconnectAttempts,
+ reconnectDelay = DEFAULT_OPTIONS.reconnectDelay,
+ pingInterval = DEFAULT_OPTIONS.pingInterval,
+ handlers,
+ } = options;
+
+ // State
+ const [state, setState] = useState('disconnected');
+ const [clientId, setClientId] = useState(null);
+ const [error, setError] = useState(null);
+ const [reconnectAttempts, setReconnectAttempts] = useState(0);
+
+ // Refs for mutable values
+ const wsRef = useRef(null);
+ const reconnectTimeoutRef = useRef(null);
+ const pingIntervalRef = useRef(null);
+ const handlersRef = useRef(handlers);
+ const shouldReconnectRef = useRef(true);
+
+ // Keep handlers ref up to date
+ useEffect(() => {
+ handlersRef.current = handlers;
+ }, [handlers]);
+
+ /**
+ * Clear all timers
+ */
+ const clearTimers = useCallback(() => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ reconnectTimeoutRef.current = null;
+ }
+ if (pingIntervalRef.current) {
+ clearInterval(pingIntervalRef.current);
+ pingIntervalRef.current = null;
+ }
+ }, []);
+
+ /**
+ * Start the ping interval
+ */
+ const startPingInterval = useCallback(() => {
+ if (pingInterval > 0 && wsRef.current?.readyState === WebSocket.OPEN) {
+ pingIntervalRef.current = setInterval(() => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: 'ping' }));
+ }
+ }, pingInterval);
+ }
+ }, [pingInterval]);
+
+ /**
+ * Handle incoming messages from the server
+ */
+ const handleMessage = useCallback((event: MessageEvent) => {
+ try {
+ const message = JSON.parse(event.data) as TypedServerMessage;
+
+ // Debug: Log all incoming messages
+ if (message.type === 'session_output') {
+ console.log(`[WebSocket] RAW message received:`, message);
+ }
+
+ // Call the generic message handler
+ handlersRef.current?.onMessage?.(message);
+
+ switch (message.type) {
+ case 'connected': {
+ const connectedMsg = message as ConnectedMessage;
+ setClientId(connectedMsg.clientId);
+ if (connectedMsg.authenticated) {
+ setState('authenticated');
+ handlersRef.current?.onConnectionChange?.('authenticated');
+ } else {
+ setState('connected');
+ handlersRef.current?.onConnectionChange?.('connected');
+ }
+ setError(null);
+ setReconnectAttempts(0);
+ startPingInterval();
+ break;
+ }
+
+ case 'auth_required': {
+ const authReqMsg = message as AuthRequiredMessage;
+ setClientId(authReqMsg.clientId);
+ setState('connected');
+ handlersRef.current?.onConnectionChange?.('connected');
+ break;
+ }
+
+ case 'auth_success': {
+ const authSuccessMsg = message as AuthSuccessMessage;
+ setClientId(authSuccessMsg.clientId);
+ setState('authenticated');
+ handlersRef.current?.onConnectionChange?.('authenticated');
+ setError(null);
+ break;
+ }
+
+ case 'auth_failed': {
+ const authFailedMsg = message as AuthFailedMessage;
+ setError(authFailedMsg.message);
+ handlersRef.current?.onError?.(authFailedMsg.message);
+ break;
+ }
+
+ case 'sessions_list': {
+ const sessionsMsg = message as SessionsListMessage;
+ handlersRef.current?.onSessionsUpdate?.(sessionsMsg.sessions);
+ break;
+ }
+
+ case 'session_state_change': {
+ const stateChangeMsg = message as SessionStateChangeMessage;
+ handlersRef.current?.onSessionStateChange?.(
+ stateChangeMsg.sessionId,
+ stateChangeMsg.state,
+ {
+ name: stateChangeMsg.name,
+ toolType: stateChangeMsg.toolType,
+ inputMode: stateChangeMsg.inputMode,
+ cwd: stateChangeMsg.cwd,
+ }
+ );
+ break;
+ }
+
+ case 'session_added': {
+ const addedMsg = message as SessionAddedMessage;
+ handlersRef.current?.onSessionAdded?.(addedMsg.session);
+ break;
+ }
+
+ case 'session_removed': {
+ const removedMsg = message as SessionRemovedMessage;
+ handlersRef.current?.onSessionRemoved?.(removedMsg.sessionId);
+ break;
+ }
+
+ case 'active_session_changed': {
+ const activeMsg = message as ActiveSessionChangedMessage;
+ handlersRef.current?.onActiveSessionChanged?.(activeMsg.sessionId);
+ break;
+ }
+
+ case 'session_output': {
+ const outputMsg = message as SessionOutputMessage;
+ console.log(`[WebSocket] Received session_output: session=${outputMsg.sessionId}, source=${outputMsg.source}, dataLen=${outputMsg.data?.length || 0}, hasHandler=${!!handlersRef.current?.onSessionOutput}`);
+ handlersRef.current?.onSessionOutput?.(outputMsg.sessionId, outputMsg.data, outputMsg.source);
+ break;
+ }
+
+ case 'session_exit': {
+ const exitMsg = message as SessionExitMessage;
+ handlersRef.current?.onSessionExit?.(exitMsg.sessionId, exitMsg.exitCode);
+ break;
+ }
+
+ case 'user_input': {
+ const inputMsg = message as UserInputMessage;
+ handlersRef.current?.onUserInput?.(inputMsg.sessionId, inputMsg.command, inputMsg.inputMode);
+ break;
+ }
+
+ case 'theme': {
+ const themeMsg = message as ThemeMessage;
+ handlersRef.current?.onThemeUpdate?.(themeMsg.theme);
+ break;
+ }
+
+ case 'custom_commands': {
+ const commandsMsg = message as CustomCommandsMessage;
+ handlersRef.current?.onCustomCommands?.(commandsMsg.commands);
+ break;
+ }
+
+ case 'error': {
+ const errorMsg = message as ErrorMessage;
+ setError(errorMsg.message);
+ handlersRef.current?.onError?.(errorMsg.message);
+ break;
+ }
+
+ case 'pong':
+ // Heartbeat response - no action needed
+ break;
+
+ default:
+ // Unknown message type - ignore or log for debugging
+ break;
+ }
+ } catch (err) {
+ webLogger.error('Failed to parse WebSocket message', 'WebSocket', err);
+ }
+ }, [startPingInterval]);
+
+ /**
+ * Attempt to reconnect to the server
+ */
+ const attemptReconnect = useCallback(() => {
+ if (!shouldReconnectRef.current || !autoReconnect) {
+ return;
+ }
+
+ if (reconnectAttempts >= maxReconnectAttempts) {
+ setError(`Failed to connect after ${maxReconnectAttempts} attempts`);
+ handlersRef.current?.onError?.(`Failed to connect after ${maxReconnectAttempts} attempts`);
+ return;
+ }
+
+ reconnectTimeoutRef.current = setTimeout(() => {
+ setReconnectAttempts((prev) => prev + 1);
+ // We'll call connect which is defined below
+ connectInternal();
+ }, reconnectDelay);
+ }, [autoReconnect, maxReconnectAttempts, reconnectAttempts, reconnectDelay]);
+
+ /**
+ * Internal connect function (to avoid circular dependency)
+ */
+ const connectInternal = useCallback(() => {
+ // Clean up existing connection
+ if (wsRef.current) {
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+ clearTimers();
+
+ // Build the URL using config (token is in URL path, not query param)
+ const url = buildWebSocketUrl(baseUrl);
+
+ setState('connecting');
+ handlersRef.current?.onConnectionChange?.('connecting');
+
+ try {
+ const ws = new WebSocket(url);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ // State will be set when we receive the 'connected' or 'auth_required' message
+ setState('authenticating');
+ handlersRef.current?.onConnectionChange?.('authenticating');
+ };
+
+ ws.onmessage = handleMessage;
+
+ ws.onerror = (event) => {
+ webLogger.error('WebSocket connection error', 'WebSocket', event);
+ setError('WebSocket connection error');
+ handlersRef.current?.onError?.('WebSocket connection error');
+ };
+
+ ws.onclose = (event) => {
+ clearTimers();
+ wsRef.current = null;
+ setState('disconnected');
+ handlersRef.current?.onConnectionChange?.('disconnected');
+
+ // Attempt to reconnect if not a clean close
+ if (event.code !== 1000 && shouldReconnectRef.current) {
+ attemptReconnect();
+ }
+ };
+ } catch (err) {
+ webLogger.error('Failed to create WebSocket', 'WebSocket', err);
+ setError('Failed to create WebSocket connection');
+ handlersRef.current?.onError?.('Failed to create WebSocket connection');
+ setState('disconnected');
+ handlersRef.current?.onConnectionChange?.('disconnected');
+ }
+ }, [baseUrl, clearTimers, handleMessage, attemptReconnect]);
+
+ /**
+ * Connect to the WebSocket server
+ */
+ const connect = useCallback(() => {
+ shouldReconnectRef.current = true;
+ setReconnectAttempts(0);
+ setError(null);
+ connectInternal();
+ }, [connectInternal]);
+
+ /**
+ * Disconnect from the WebSocket server
+ */
+ const disconnect = useCallback(() => {
+ shouldReconnectRef.current = false;
+ clearTimers();
+
+ if (wsRef.current) {
+ wsRef.current.close(1000, 'Client disconnect');
+ wsRef.current = null;
+ }
+
+ setState('disconnected');
+ setClientId(null);
+ handlersRef.current?.onConnectionChange?.('disconnected');
+ }, [clearTimers]);
+
+ /**
+ * Send an authentication token
+ */
+ const authenticate = useCallback((authToken: string) => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: 'auth', token: authToken }));
+ setState('authenticating');
+ handlersRef.current?.onConnectionChange?.('authenticating');
+ }
+ }, []);
+
+ /**
+ * Send a ping message
+ */
+ const ping = useCallback(() => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: 'ping' }));
+ }
+ }, []);
+
+ /**
+ * Send a raw message to the server
+ */
+ const send = useCallback((message: object): boolean => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify(message));
+ return true;
+ }
+ return false;
+ }, []);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ shouldReconnectRef.current = false;
+ clearTimers();
+ if (wsRef.current) {
+ wsRef.current.close(1000, 'Component unmount');
+ wsRef.current = null;
+ }
+ };
+ }, [clearTimers]);
+
+ // Derived state
+ const isAuthenticated = state === 'authenticated';
+ const isConnected = state === 'connected' || state === 'authenticated' || state === 'authenticating';
+
+ return {
+ state,
+ isAuthenticated,
+ isConnected,
+ clientId,
+ error,
+ reconnectAttempts,
+ connect,
+ disconnect,
+ authenticate,
+ ping,
+ send,
+ };
+}
+
+export default useWebSocket;
diff --git a/src/web/index.css b/src/web/index.css
new file mode 100644
index 000000000..9c886e4a7
--- /dev/null
+++ b/src/web/index.css
@@ -0,0 +1,345 @@
+/**
+ * Maestro Web Interface Styles
+ *
+ * Base styles and CSS custom properties for the web interface.
+ * Theme colors are injected via JavaScript from ThemeProvider.
+ */
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* CSS Custom Properties - Default values (overridden by theme) */
+:root {
+ /* Colors */
+ --color-background: #1a1a2e;
+ --color-surface: #16213e;
+ --color-surface-elevated: #1f2e54;
+ --color-border: #2d3a5a;
+ --color-text-main: #eaeaea;
+ --color-text-muted: #8892b0;
+ --color-accent: #4a9eff;
+ --color-accent-hover: #3a8eef;
+ --color-success: #22c55e;
+ --color-warning: #f59e0b;
+ --color-error: #ef4444;
+
+ /* Typography */
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
+
+ /* Spacing */
+ --spacing-xs: 4px;
+ --spacing-sm: 8px;
+ --spacing-md: 16px;
+ --spacing-lg: 24px;
+ --spacing-xl: 32px;
+
+ /* Border Radius */
+ --radius-sm: 4px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --radius-full: 9999px;
+
+ /* Transitions */
+ --transition-fast: 150ms ease;
+ --transition-normal: 200ms ease;
+ --transition-slow: 300ms ease;
+
+ /* Z-Index layers */
+ --z-dropdown: 100;
+ --z-sticky: 200;
+ --z-modal-backdrop: 300;
+ --z-modal: 400;
+ --z-toast: 500;
+}
+
+/* Base styles */
+html {
+ font-size: 16px;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ font-family: var(--font-mono);
+ background-color: var(--color-background);
+ color: var(--color-text-main);
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* Selection styling */
+::selection {
+ background-color: var(--color-accent);
+ color: white;
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--color-surface);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: var(--radius-full);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-text-muted);
+}
+
+/* Focus styles */
+:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+}
+
+/* Remove default focus outline (we use focus-visible) */
+:focus:not(:focus-visible) {
+ outline: none;
+}
+
+/* Link styles */
+a {
+ color: var(--color-accent);
+ text-decoration: none;
+ transition: color var(--transition-fast);
+}
+
+a:hover {
+ color: var(--color-accent-hover);
+}
+
+/* Button reset */
+button {
+ font-family: inherit;
+ font-size: inherit;
+ cursor: pointer;
+ background: none;
+ border: none;
+ padding: 0;
+}
+
+/* Input reset */
+input,
+textarea,
+select {
+ font-family: inherit;
+ font-size: inherit;
+ background: none;
+ border: none;
+}
+
+/* Mobile-specific styles */
+@media (max-width: 767px) {
+ /* Prevent text size adjustment */
+ html {
+ -webkit-text-size-adjust: none;
+ text-size-adjust: none;
+ }
+
+ /* Larger tap targets for accessibility (44px minimum per Apple HIG) */
+ button,
+ a,
+ input,
+ select,
+ textarea {
+ min-height: 44px;
+ }
+
+ /* Disable pull-to-refresh on body (we'll handle it ourselves) */
+ body {
+ overscroll-behavior-y: contain;
+ }
+
+ /* Prevent zoom on input focus (iOS Safari) */
+ input,
+ textarea,
+ select {
+ font-size: 16px !important;
+ }
+}
+
+/* Dynamic viewport height support for mobile browsers */
+/* dvh accounts for mobile browser chrome (address bar) */
+html, body, #root {
+ min-height: 100dvh;
+ min-height: -webkit-fill-available;
+}
+
+/* Fallback for browsers that don't support dvh */
+@supports not (min-height: 100dvh) {
+ html, body, #root {
+ min-height: 100vh;
+ }
+}
+
+/* iOS-specific fixes */
+@supports (-webkit-touch-callout: none) {
+ /* Fix for iOS rubber-banding */
+ body {
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ #root {
+ height: 100%;
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ /* Prevent iOS zoom on double tap */
+ * {
+ touch-action: manipulation;
+ }
+}
+
+/* Prevent touch callout (long-press menu) on interactive elements */
+button,
+a,
+input,
+select,
+textarea {
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+/* Allow text selection in content areas */
+p, span, div, article, section {
+ -webkit-user-select: text;
+ user-select: text;
+}
+
+/* Smooth momentum scrolling */
+.scrollable {
+ -webkit-overflow-scrolling: touch;
+ overflow-y: auto;
+}
+
+/* Safe area insets for iOS */
+@supports (padding-top: env(safe-area-inset-top)) {
+ .safe-area-top {
+ padding-top: env(safe-area-inset-top);
+ }
+
+ .safe-area-bottom {
+ padding-bottom: env(safe-area-inset-bottom);
+ }
+
+ .safe-area-left {
+ padding-left: env(safe-area-inset-left);
+ }
+
+ .safe-area-right {
+ padding-right: env(safe-area-inset-right);
+ }
+}
+
+/* Animation keyframes */
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slideDown {
+ from {
+ transform: translateY(-100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+/* Utility classes */
+.animate-spin {
+ animation: spin 1s linear infinite;
+}
+
+.animate-pulse {
+ animation: pulse 2s ease-in-out infinite;
+}
+
+.animate-fadeIn {
+ animation: fadeIn var(--transition-normal);
+}
+
+.animate-slideUp {
+ animation: slideUp var(--transition-normal);
+}
+
+.animate-slideDown {
+ animation: slideDown var(--transition-normal);
+}
+
+/* Hide scrollbar but keep functionality */
+.scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+.scrollbar-hide::-webkit-scrollbar {
+ display: none;
+}
+
+/* Truncate text */
+.truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Line clamp */
+.line-clamp-2 {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.line-clamp-3 {
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
diff --git a/src/web/index.html b/src/web/index.html
new file mode 100644
index 000000000..3668603bc
--- /dev/null
+++ b/src/web/index.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Maestro Web
+
+
+
+
+
+
+
+
+
+
diff --git a/src/web/index.ts b/src/web/index.ts
new file mode 100644
index 000000000..06afc331f
--- /dev/null
+++ b/src/web/index.ts
@@ -0,0 +1,15 @@
+/**
+ * Maestro Web Interface
+ *
+ * This module contains shared components, hooks, and utilities
+ * for the Maestro web interface (both mobile and desktop web).
+ */
+
+// Components
+export * from './components';
+
+// Hooks
+export * from './hooks';
+
+// Utilities
+export * from './utils';
diff --git a/src/web/main.tsx b/src/web/main.tsx
new file mode 100644
index 000000000..6cdbb6fb8
--- /dev/null
+++ b/src/web/main.tsx
@@ -0,0 +1,252 @@
+/**
+ * Maestro Web Interface Entry Point
+ *
+ * Remote control interface for mobile/tablet devices.
+ * Provides session monitoring and command input from anywhere on your network.
+ */
+
+import React, { StrictMode, lazy, Suspense, useEffect, useState, createContext, useContext, useCallback } from 'react';
+import { createRoot } from 'react-dom/client';
+import { ThemeProvider } from './components/ThemeProvider';
+import { registerServiceWorker, isOffline } from './utils/serviceWorker';
+import {
+ getMaestroConfig,
+ isDashboardMode,
+ isSessionMode,
+ getCurrentSessionId,
+ getDashboardUrl,
+ getSessionUrl,
+} from './utils/config';
+import { webLogger } from './utils/logger';
+import type { Theme } from '../shared/theme-types';
+import './index.css';
+
+/**
+ * Context for offline status
+ * Provides offline state to all components in the app
+ */
+interface OfflineContextValue {
+ isOffline: boolean;
+}
+
+const OfflineContext = createContext({ isOffline: false });
+
+/**
+ * Hook to access offline status
+ */
+export function useOfflineStatus(): boolean {
+ return useContext(OfflineContext).isOffline;
+}
+
+/**
+ * Context for Maestro mode (dashboard vs session)
+ */
+interface MaestroModeContextValue {
+ /** Whether we're viewing the dashboard (all live sessions) */
+ isDashboard: boolean;
+ /** Whether we're viewing a specific session */
+ isSession: boolean;
+ /** Current session ID (if in session mode) */
+ sessionId: string | null;
+ /** Security token for API/WS calls */
+ securityToken: string;
+ /** Navigate to dashboard */
+ goToDashboard: () => void;
+ /** Navigate to a specific session */
+ goToSession: (sessionId: string) => void;
+}
+
+const MaestroModeContext = createContext({
+ isDashboard: true,
+ isSession: false,
+ sessionId: null,
+ securityToken: '',
+ goToDashboard: () => {},
+ goToSession: () => {},
+});
+
+/**
+ * Hook to access Maestro mode context
+ */
+export function useMaestroMode(): MaestroModeContextValue {
+ return useContext(MaestroModeContext);
+}
+
+/**
+ * Context for theme updates from WebSocket
+ * Allows the mobile app to update the theme when received from desktop
+ */
+interface ThemeUpdateContextValue {
+ /** Current theme from desktop app (null if using device preference) */
+ desktopTheme: Theme | null;
+ /** Update the theme when received from desktop app */
+ setDesktopTheme: (theme: Theme) => void;
+}
+
+const ThemeUpdateContext = createContext({
+ desktopTheme: null,
+ setDesktopTheme: () => {},
+});
+
+/**
+ * Hook to access and update the desktop theme
+ * Used by mobile app to set theme when received via WebSocket
+ */
+export function useDesktopTheme(): ThemeUpdateContextValue {
+ return useContext(ThemeUpdateContext);
+}
+
+// Lazy load the web app
+// Both mobile and desktop use the same remote control interface
+const WebApp = lazy(() =>
+ import(/* webpackChunkName: "mobile" */ './mobile').catch(() => ({
+ default: () => ,
+ }))
+);
+
+/**
+ * Placeholder component shown while the actual app loads
+ * or if there's an error loading the app module
+ */
+function PlaceholderApp() {
+ return (
+
+
Maestro Web
+
+ Remote control interface
+
+
+ Connect to your Maestro desktop app to get started
+
+
+ );
+}
+
+/**
+ * Loading fallback component
+ */
+function LoadingFallback() {
+ return (
+
+ );
+}
+
+/**
+ * Main App component - renders the remote control interface
+ */
+function App() {
+ const [offline, setOffline] = useState(isOffline());
+ const [desktopTheme, setDesktopTheme] = useState(null);
+
+ // Get config on mount
+ const config = getMaestroConfig();
+
+ // Theme update context value
+ const themeUpdateContextValue: ThemeUpdateContextValue = {
+ desktopTheme,
+ setDesktopTheme: useCallback((theme: Theme) => {
+ webLogger.debug(`Desktop theme received: ${theme.name} (${theme.mode})`, 'App');
+ setDesktopTheme(theme);
+ }, []),
+ };
+
+ // Mode context value
+ const modeContextValue: MaestroModeContextValue = {
+ isDashboard: isDashboardMode(),
+ isSession: isSessionMode(),
+ sessionId: getCurrentSessionId(),
+ securityToken: config.securityToken,
+ goToDashboard: () => {
+ window.location.href = getDashboardUrl();
+ },
+ goToSession: (sessionId: string) => {
+ window.location.href = getSessionUrl(sessionId);
+ },
+ };
+
+ // Register service worker for offline capability
+ useEffect(() => {
+ registerServiceWorker({
+ onSuccess: (registration) => {
+ webLogger.debug(`Service worker ready: ${registration.scope}`, 'App');
+ },
+ onUpdate: () => {
+ webLogger.info('New content available, refresh recommended', 'App');
+ // Could show a toast/notification here prompting user to refresh
+ },
+ onOfflineChange: (newOfflineStatus) => {
+ webLogger.debug(`Offline status changed: ${newOfflineStatus}`, 'App');
+ setOffline(newOfflineStatus);
+ },
+ });
+ }, []);
+
+ // Log mode info on mount
+ useEffect(() => {
+ webLogger.debug(`Mode: ${modeContextValue.isDashboard ? 'dashboard' : `session:${modeContextValue.sessionId}`}`, 'App');
+ }, []);
+
+ return (
+
+
+
+ {/*
+ Enable useDevicePreference to respect the device's dark/light mode preference.
+ When no theme is provided from the desktop app via WebSocket, the web interface
+ will automatically use a dark or light theme based on the user's device settings.
+ Once the desktop app sends a theme (via desktopTheme), it will override the device preference.
+ */}
+
+ }>
+
+
+
+
+
+
+ );
+}
+
+// Mount the application
+const container = document.getElementById('root');
+if (container) {
+ const root = createRoot(container);
+ root.render(
+
+
+
+ );
+} else {
+ webLogger.error('Root element not found', 'App');
+}
diff --git a/src/web/mobile/AllSessionsView.tsx b/src/web/mobile/AllSessionsView.tsx
new file mode 100644
index 000000000..f9791b58e
--- /dev/null
+++ b/src/web/mobile/AllSessionsView.tsx
@@ -0,0 +1,643 @@
+/**
+ * AllSessionsView component for Maestro mobile web interface
+ *
+ * A full-screen view displaying all sessions as larger cards.
+ * This view is triggered when:
+ * - User has many sessions (default threshold: 6+)
+ * - User taps "All Sessions" button in the session pill bar
+ *
+ * Features:
+ * - Larger, touch-friendly session cards
+ * - Sessions organized by group with collapsible group headers
+ * - Status indicator, mode badge, and working directory visible
+ * - Swipe down to dismiss / back button at top
+ * - Search/filter sessions
+ */
+
+import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
+import { useThemeColors } from '../components/ThemeProvider';
+import { StatusDot, type SessionStatus } from '../components/Badge';
+import type { Session, GroupInfo } from '../hooks/useSessions';
+import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
+
+/**
+ * Session card component for the All Sessions view
+ * Larger and more detailed than the session pills
+ */
+interface SessionCardProps {
+ session: Session;
+ isActive: boolean;
+ onSelect: (sessionId: string) => void;
+}
+
+function MobileSessionCard({ session, isActive, onSelect }: SessionCardProps) {
+ const colors = useThemeColors();
+
+ // Map session state to status for StatusDot
+ const getStatus = (): SessionStatus => {
+ const state = session.state as string;
+ if (state === 'idle') return 'idle';
+ if (state === 'busy') return 'busy';
+ if (state === 'connecting') return 'connecting';
+ return 'error';
+ };
+
+ // Get status label
+ const getStatusLabel = (): string => {
+ const state = session.state as string;
+ if (state === 'idle') return 'Ready';
+ if (state === 'busy') return 'Thinking...';
+ if (state === 'connecting') return 'Connecting...';
+ return 'Error';
+ };
+
+ // Get tool type display name
+ const getToolTypeLabel = (): string => {
+ const toolTypeMap: Record = {
+ 'claude-code': 'Claude Code',
+ 'aider-gemini': 'Aider (Gemini)',
+ 'qwen-coder': 'Qwen Coder',
+ 'terminal': 'Terminal',
+ };
+ return toolTypeMap[session.toolType] || session.toolType;
+ };
+
+ // Truncate path for display
+ const truncatePath = (path: string, maxLength: number = 40): string => {
+ if (path.length <= maxLength) return path;
+ const parts = path.split('/');
+ if (parts.length <= 2) return `...${path.slice(-maxLength + 3)}`;
+ return `.../${parts.slice(-2).join('/')}`;
+ };
+
+ const handleClick = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ onSelect(session.id);
+ }, [session.id, onSelect]);
+
+ return (
+
+ );
+}
+
+/**
+ * Group section component with collapsible header
+ */
+interface GroupSectionProps {
+ groupId: string;
+ name: string;
+ emoji: string | null;
+ sessions: Session[];
+ activeSessionId: string | null;
+ onSelectSession: (sessionId: string) => void;
+ isCollapsed: boolean;
+ onToggleCollapse: (groupId: string) => void;
+}
+
+function GroupSection({
+ groupId,
+ name,
+ emoji,
+ sessions,
+ activeSessionId,
+ onSelectSession,
+ isCollapsed,
+ onToggleCollapse,
+}: GroupSectionProps) {
+ const colors = useThemeColors();
+
+ const handleToggle = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ onToggleCollapse(groupId);
+ }, [groupId, onToggleCollapse]);
+
+ return (
+
+ {/* Group header */}
+
+
+ {/* Session cards */}
+ {!isCollapsed && (
+
+ {sessions.map((session) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+/**
+ * Props for AllSessionsView component
+ */
+export interface AllSessionsViewProps {
+ /** List of sessions to display */
+ sessions: Session[];
+ /** ID of the currently active session */
+ activeSessionId: string | null;
+ /** Callback when a session is selected */
+ onSelectSession: (sessionId: string) => void;
+ /** Callback to close the All Sessions view */
+ onClose: () => void;
+ /** Optional filter/search query */
+ searchQuery?: string;
+}
+
+/**
+ * AllSessionsView component
+ *
+ * Full-screen view showing all sessions as larger cards, organized by group.
+ * Provides better visibility when there are many sessions.
+ */
+export function AllSessionsView({
+ sessions,
+ activeSessionId,
+ onSelectSession,
+ onClose,
+ searchQuery = '',
+}: AllSessionsViewProps) {
+ const colors = useThemeColors();
+ const [collapsedGroups, setCollapsedGroups] = useState>(new Set());
+ const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
+ const containerRef = useRef(null);
+
+ // Filter sessions by search query
+ const filteredSessions = useMemo(() => {
+ if (!localSearchQuery.trim()) return sessions;
+ const query = localSearchQuery.toLowerCase();
+ return sessions.filter(
+ (session) =>
+ session.name.toLowerCase().includes(query) ||
+ session.cwd.toLowerCase().includes(query) ||
+ (session.toolType && session.toolType.toLowerCase().includes(query))
+ );
+ }, [sessions, localSearchQuery]);
+
+ // Organize sessions by group
+ const sessionsByGroup = useMemo((): Record => {
+ const groups: Record = {};
+
+ for (const session of filteredSessions) {
+ const groupKey = session.groupId || 'ungrouped';
+
+ if (!groups[groupKey]) {
+ groups[groupKey] = {
+ id: session.groupId || null,
+ name: session.groupName || 'Ungrouped',
+ emoji: session.groupEmoji || null,
+ sessions: [],
+ };
+ }
+ groups[groupKey].sessions.push(session);
+ }
+
+ return groups;
+ }, [filteredSessions]);
+
+ // Get sorted group keys (ungrouped last)
+ const sortedGroupKeys = useMemo(() => {
+ const keys = Object.keys(sessionsByGroup);
+ return keys.sort((a, b) => {
+ if (a === 'ungrouped') return 1;
+ if (b === 'ungrouped') return -1;
+ return sessionsByGroup[a].name.localeCompare(sessionsByGroup[b].name);
+ });
+ }, [sessionsByGroup]);
+
+ // Toggle group collapse
+ const handleToggleCollapse = useCallback((groupId: string) => {
+ setCollapsedGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(groupId)) {
+ next.delete(groupId);
+ } else {
+ next.add(groupId);
+ }
+ return next;
+ });
+ }, []);
+
+ // Handle session selection and close view
+ const handleSelectSession = useCallback(
+ (sessionId: string) => {
+ onSelectSession(sessionId);
+ onClose();
+ },
+ [onSelectSession, onClose]
+ );
+
+ // Handle close button
+ const handleClose = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ onClose();
+ }, [onClose]);
+
+ // Handle search input change
+ const handleSearchChange = useCallback((e: React.ChangeEvent) => {
+ setLocalSearchQuery(e.target.value);
+ }, []);
+
+ // Clear search
+ const handleClearSearch = useCallback(() => {
+ setLocalSearchQuery('');
+ }, []);
+
+ // Close on escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onClose();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
+ return (
+
+ {/* Header */}
+
+
+ All Sessions
+
+
+
+
+ {/* Search bar */}
+
+
+ {/* Search icon */}
+ 🔍
+
+ {localSearchQuery && (
+
+ )}
+
+
+
+ {/* Session list */}
+
+ {filteredSessions.length === 0 ? (
+
+
+ {localSearchQuery ? 'No sessions found' : 'No sessions available'}
+
+
+ {localSearchQuery
+ ? `No sessions match "${localSearchQuery}"`
+ : 'Create a session in the desktop app to get started'}
+
+
+ ) : sortedGroupKeys.length === 1 && sortedGroupKeys[0] === 'ungrouped' ? (
+ // If only ungrouped sessions, render without group header
+
+ {filteredSessions.map((session) => (
+
+ ))}
+
+ ) : (
+ // Render with group sections
+ sortedGroupKeys.map((groupKey) => {
+ const group = sessionsByGroup[groupKey];
+ return (
+
+ );
+ })
+ )}
+
+
+ {/* Animation keyframes */}
+
+
+ );
+}
+
+export default AllSessionsView;
diff --git a/src/web/mobile/App.tsx b/src/web/mobile/App.tsx
new file mode 100644
index 000000000..1aee5b606
--- /dev/null
+++ b/src/web/mobile/App.tsx
@@ -0,0 +1,1299 @@
+/**
+ * Maestro Web Remote Control
+ *
+ * Lightweight interface for controlling sessions from mobile/tablet devices.
+ * Focused on quick command input and session monitoring.
+ */
+
+import React, { useEffect, useCallback, useState, useMemo, useRef } from 'react';
+import { useThemeColors } from '../components/ThemeProvider';
+import { useWebSocket, type WebSocketState, type CustomCommand } from '../hooks/useWebSocket';
+import { useCommandHistory } from '../hooks/useCommandHistory';
+import { useNotifications } from '../hooks/useNotifications';
+import { useUnreadBadge } from '../hooks/useUnreadBadge';
+import { useOfflineQueue } from '../hooks/useOfflineQueue';
+import { Badge, type BadgeVariant } from '../components/Badge';
+import { useOfflineStatus, useMaestroMode, useDesktopTheme } from '../main';
+import { buildApiUrl } from '../utils/config';
+import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
+import { webLogger } from '../utils/logger';
+import type { Theme } from '../../shared/theme-types';
+import { SessionPillBar } from './SessionPillBar';
+import { AllSessionsView } from './AllSessionsView';
+import { CommandInputBar, type InputMode } from './CommandInputBar';
+import { DEFAULT_SLASH_COMMANDS, type SlashCommand } from './SlashCommandAutocomplete';
+import { CommandHistoryDrawer } from './CommandHistoryDrawer';
+import { RecentCommandChips } from './RecentCommandChips';
+import { ResponseViewer, type ResponseItem } from './ResponseViewer';
+import { OfflineQueueBanner } from './OfflineQueueBanner';
+import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
+import { MessageHistory, type LogEntry } from './MessageHistory';
+import type { Session, LastResponsePreview } from '../hooks/useSessions';
+
+/**
+ * Map WebSocket state to display properties
+ */
+interface ConnectionStatusConfig {
+ label: string;
+ variant: BadgeVariant;
+ pulse: boolean;
+}
+
+const CONNECTION_STATUS_CONFIG: Record = {
+ offline: {
+ label: 'Offline',
+ variant: 'error',
+ pulse: false,
+ },
+ disconnected: {
+ label: 'Disconnected',
+ variant: 'error',
+ pulse: false,
+ },
+ connecting: {
+ label: 'Connecting...',
+ variant: 'connecting',
+ pulse: true,
+ },
+ authenticating: {
+ label: 'Authenticating...',
+ variant: 'connecting',
+ pulse: true,
+ },
+ connected: {
+ label: 'Connected',
+ variant: 'success',
+ pulse: false,
+ },
+ authenticated: {
+ label: 'Connected',
+ variant: 'success',
+ pulse: false,
+ },
+};
+
+/**
+ * Format cost in USD for display
+ */
+function formatCost(cost: number): string {
+ if (cost < 0.01) return `$${cost.toFixed(4)}`;
+ if (cost < 1.0) return `$${cost.toFixed(3)}`;
+ return `$${cost.toFixed(2)}`;
+}
+
+/**
+ * Calculate context usage percentage from usage stats
+ */
+function calculateContextUsage(usageStats?: Session['usageStats'] | null): number | null {
+ if (!usageStats) return null;
+ const { inputTokens, outputTokens, contextWindow } = usageStats;
+ if (inputTokens == null || outputTokens == null || contextWindow == null || contextWindow === 0) {
+ return null;
+ }
+ return Math.min(Math.round(((inputTokens + outputTokens) / contextWindow) * 100), 100);
+}
+
+/**
+ * Header component for the mobile app
+ * Compact single-line header showing: Maestro | Session Name | Claude ID | Status | Cost | Context
+ */
+interface MobileHeaderProps {
+ connectionState: WebSocketState;
+ isOffline: boolean;
+ onRetry?: () => void;
+ activeSession?: Session | null;
+}
+
+function MobileHeader({ connectionState, isOffline, onRetry, activeSession }: MobileHeaderProps) {
+ const colors = useThemeColors();
+ const { isSession, goToDashboard } = useMaestroMode();
+
+ // Show offline status if device is offline, otherwise show connection state
+ const effectiveState = isOffline ? 'offline' : connectionState;
+ const statusConfig = CONNECTION_STATUS_CONFIG[effectiveState];
+
+ // Session status and usage
+ const sessionState = activeSession?.state || 'idle';
+ const isThinking = sessionState === 'busy';
+ const cost = activeSession?.usageStats?.totalCostUsd;
+ const contextUsage = calculateContextUsage(activeSession?.usageStats);
+
+ // Get status dot color
+ const getStatusDotColor = () => {
+ if (sessionState === 'busy') return colors.warning;
+ if (sessionState === 'error') return colors.error;
+ if (sessionState === 'connecting') return colors.warning;
+ return colors.success; // idle
+ };
+
+ return (
+
+ {/* Left: Maestro logo with wand icon */}
+
+ {/* Wand icon */}
+
+
+ Maestro
+
+
+
+ {/* Center: Session info (name + Claude session ID + status + usage) */}
+ {activeSession && (
+
+ {/* Session status dot */}
+
+
+ {/* Session name */}
+
+ {activeSession.name}
+
+
+ {/* Claude Session ID pill */}
+ {activeSession.claudeSessionId && (
+
+ {activeSession.claudeSessionId.slice(0, 8)}
+
+ )}
+
+ {/* Cost */}
+ {cost != null && cost > 0 && (
+
+ {formatCost(cost)}
+
+ )}
+
+ {/* Context usage bar */}
+ {contextUsage != null && (
+
+
+
= 90 ? colors.error : contextUsage >= 70 ? colors.warning : colors.success,
+ borderRadius: '2px',
+ }}
+ />
+
+
{contextUsage}%
+
+ )}
+
+ )}
+
+ {/* Right: Connection status */}
+
+ {statusConfig.label}
+
+
+ {/* Pulse animation for thinking state */}
+
+
+ );
+}
+
+/**
+ * Main mobile app component with WebSocket connection management
+ */
+export default function MobileApp() {
+ const colors = useThemeColors();
+ const isOffline = useOfflineStatus();
+ const { setDesktopTheme } = useDesktopTheme();
+ const [lastRefreshTime, setLastRefreshTime] = useState
(null);
+ const [sessions, setSessions] = useState([]);
+ const [activeSessionId, setActiveSessionId] = useState(null);
+ const [showAllSessions, setShowAllSessions] = useState(false);
+ const [commandInput, setCommandInput] = useState('');
+ const [showHistoryDrawer, setShowHistoryDrawer] = useState(false);
+ const [showResponseViewer, setShowResponseViewer] = useState(false);
+ const [selectedResponse, setSelectedResponse] = useState(null);
+ const [responseIndex, setResponseIndex] = useState(0);
+
+ // Message history state (logs from active session)
+ const [sessionLogs, setSessionLogs] = useState<{ aiLogs: LogEntry[]; shellLogs: LogEntry[] }>({
+ aiLogs: [],
+ shellLogs: [],
+ });
+ const [isLoadingLogs, setIsLoadingLogs] = useState(false);
+
+ // Custom slash commands from desktop
+ const [customCommands, setCustomCommands] = useState([]);
+
+ // Input expansion state for small screens (drawer-like behavior)
+ const [isInputExpanded, setIsInputExpanded] = useState(false);
+
+ // Detect if on a small screen (phone vs tablet/iPad)
+ // Use 768px as breakpoint - below this is considered "small"
+ const [isSmallScreen, setIsSmallScreen] = useState(
+ typeof window !== 'undefined' ? window.innerHeight < 700 : false
+ );
+
+ // Track screen size changes
+ useEffect(() => {
+ const handleResize = () => {
+ setIsSmallScreen(window.innerHeight < 700);
+ };
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ // Command history hook
+ const {
+ history: commandHistory,
+ addCommand: addToHistory,
+ removeCommand: removeFromHistory,
+ clearHistory,
+ getUniqueCommands,
+ } = useCommandHistory();
+
+ // Notification permission hook - requests permission on first visit
+ const {
+ permission: notificationPermission,
+ showNotification,
+ } = useNotifications({
+ autoRequest: true,
+ requestDelay: 3000, // Wait 3 seconds before prompting
+ onGranted: () => {
+ webLogger.debug('Notification permission granted', 'Mobile');
+ triggerHaptic(HAPTIC_PATTERNS.success);
+ },
+ onDenied: () => {
+ webLogger.debug('Notification permission denied', 'Mobile');
+ },
+ });
+
+ // Unread badge hook - tracks unread responses and updates app badge
+ const {
+ addUnread: addUnreadResponse,
+ markAllRead: markAllResponsesRead,
+ unreadCount,
+ } = useUnreadBadge({
+ autoClearOnVisible: true, // Clear badge when user opens the app
+ onCountChange: (count) => {
+ webLogger.debug(`Unread response count: ${count}`, 'Mobile');
+ },
+ });
+
+ // Track previous session states for detecting busy -> idle transitions
+ const previousSessionStatesRef = useRef