import React, { useState, useEffect, useRef, useCallback, startTransition, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { openSettingsModal } from '@/shared/state/settingsSlice';
import { getLastInteractedBrowser, getKeepAliveBrowserIds, setLastInteractedBrowser, clearLastInteractedBrowser } from '@/shared/browserFocus';
import { getWebview } from '@/shared/browserRegistry';
import { applyBrowserZoom } from '@/shared/browserZoom';
import Box from '@mui/material/Box';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Collapse from '@mui/material/Collapse';
import Button from '@mui/material/Button';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import InputBase from '@mui/material/InputBase';
// One outlined icon language for the sidebar: thin monoline glyphs (not the filled Material clip-art) so the rail reads as designed, not assembled.
import { LayoutDashboard } from 'lucide-react';
import { LayoutGrid } from 'lucide-react';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { Settings as LucideSettings } from 'lucide-react';
import { ArrowLeft, ArrowRight, Plus, Clock, Search as SearchGlyph, X as CloseGlyph } from 'lucide-react';
import ButtonBase from '@mui/material/ButtonBase';
import { AnimatedPanelLeft } from './animatedIcons';

const SEARCH_HOTKEY_LABEL = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl+K';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import CloseIcon from '@mui/icons-material/Close';
import LinearProgress from '@mui/material/LinearProgress';
import CircularProgress from '@mui/material/CircularProgress';
// Settings modal lazy-loaded so its 2.3K LOC + Stripe/OAuth helpers don't ship on first paint.
const Settings = React.lazy(() => import('@/app/pages/Settings/Settings'));
import DynamicIsland from '@/app/components/overlays/DynamicIsland';
import Dashboard from '@/app/pages/Dashboard/Dashboard';
import DashboardHost from '@/app/components/Layout/DashboardHost';
import { useLastDashboardId } from '@/shared/hooks/useLastDashboardId';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { hasModelConnected as selectHasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
import { shallowEqual } from 'react-redux';
import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
import { Typewriter } from '@/app/components/feedback/Animated';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import { addBrowserCard, addBrowserTab, cycleBrowserTab, reopenLastClosed, addViewCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice';
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
import { fetchOutputs, deleteOutput, updateOutput } from '@/shared/state/outputsSlice';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
import { setInstalling } from '@/shared/state/updateSlice';
import { findBrowserByWebContentsId } from '@/shared/browserRegistry';
import { byPreviewRecency } from '@/shared/previewOrder';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';

const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 400;
// 260 matches Claude.ai's nav-sidebar width: roomy enough that names don't truncate.
const SIDEBAR_DEFAULT = 260;
const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width';
const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed';

const AppShell: React.FC = () => {
  const c = useClaudeTokens();
  const dispatch = useAppDispatch();
  const navigateRaw = useNavigate();
  // startTransition wrapper: route swap becomes non-urgent so click handler returns immediately; eliminates the "click, wait, page appears" gap on slow routes.
  const navigate = useMemo(() => {
    const fn = (...args: Parameters<typeof navigateRaw>) => {
      startTransition(() => {
        (navigateRaw as any)(...args);
      });
    };
    return fn as typeof navigateRaw;
  }, [navigateRaw]);
  const location = useLocation();
  // React Router (HashRouter) stores a monotonic index in history state. location re-renders on every nav, by which point window.history.state.idx is updated.
  const historyIdx = (window.history.state?.idx as number | undefined) ?? 0;
  const maxHistoryIdx = useRef(0);
  maxHistoryIdx.current = Math.max(maxHistoryIdx.current, historyIdx);
  const canGoBack = historyIdx > 0;
  const canGoForward = historyIdx < maxHistoryIdx.current;
  const [dashboardsExpanded, setDashboardsExpanded] = useState(true);
  const [appsExpanded, setAppsExpanded] = useState(true);
  // Desktop shell: the wallpaper canvas IS the home surface, so the sidebar starts docked away
  // (left-edge hover peeks it; the pin toggle brings it back full-time).
  const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
  const [renamingDashboardId, setRenamingDashboardId] = useState<string | null>(null);
  const [renamingAppId, setRenamingAppId] = useState<string | null>(null);
  const [renameValue, setRenameValue] = useState('');
  // Arc-style delete: the row vanishes at once but the real delete waits behind an Undo toast.
  const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null);
  const pendingDeleteRef = useRef<{ id: string; name: string } | null>(null);
  const deleteTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  // Arc-style peek: hovering an app row (with intent, not a fly-by) floats a preview beside the sidebar.
  const [peek, setPeek] = useState<{ name: string; description: string; thumbnail: string | null; top: number } | null>(null);
  const peekTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  // Arc-style Space switching: a horizontal two-finger swipe over the sidebar flips dashboards. Kept OFF
  // the canvas (which owns horizontal pan) so the two never fight. accum + cooldown = one flip per swipe.
  const swipeAccumRef = useRef(0);
  const swipeCooldownRef = useRef(false);
  const [sidebarWidth, setSidebarWidth] = useState(() => {
    try {
      const stored = localStorage.getItem(SIDEBAR_WIDTH_KEY);
      if (stored) {
        const w = Number(stored);
        if (w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) return w;
      }
    } catch {}
    return SIDEBAR_DEFAULT;
  });
  const isResizing = useRef(false);

  const updateStatus = useAppSelector((state) => state.update.status);
  const availableVersion = useAppSelector((state) => state.update.availableVersion);
  const downloadPercent = useAppSelector((state) => state.update.downloadPercent);
  const installing = useAppSelector((state) => state.update.installing);
  // Windows' Squirrel never reports a version, and a mid-download cache-clear reload wipes it, so render the name version-less instead of "OpenSwarm null".
  const verSuffix = availableVersion ? ` ${availableVersion}` : '';

  const [dismissedVersion, setDismissedVersion] = useState<string | null>(() => {
    try { return localStorage.getItem(UPDATE_DISMISS_KEY); } catch { return null; }
  });
  const [snackbarDismissed, setSnackbarDismissed] = useState(false);

  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const goOnline = () => setIsOnline(true);
    const goOffline = () => setIsOnline(false);
    window.addEventListener('online', goOnline);
    window.addEventListener('offline', goOffline);
    return () => {
      window.removeEventListener('online', goOnline);
      window.removeEventListener('offline', goOffline);
    };
  }, []);

  const modelsLoaded = useAppSelector((s) => s.models.loaded);
  // "Connected" = the user's OWN model (key/sub/pro/custom), NOT a non-empty /models list: the free-trial Haiku is always in that list now, so a byProvider-length check would falsely read as connected and hide the out-of-runs banner.
  const hasModelConnected = useAppSelector(selectHasModelConnected);
  // During an active free trial the user CAN run things, so a red "no model connected" warning is misleading and discouraging (it sits right above the working starter chips). The trial flips connection_mode back to own_key the moment it's spent, so this banner returns then, landing the connect-a-model nudge after the win, not before it.
  const freeTrialActive = useAppSelector((s) => {
    const d = s.settings.data as any;
    return !!(d && d.connection_mode === 'free-trial' && d.free_trial_token);
  });
  // Trial just ran dry (had an allotment, now 0, off the free lane): a quiet connect nudge, not the red error wall. Runs refill, so it's "for now".
  const freeTrialSpent = useAppSelector((s) => {
    const d = s.settings.data as any;
    return !!(d && (d.free_trial_runs_limit ?? 0) > 0 && d.free_trial_remaining === 0 && d.connection_mode !== 'free-trial');
  });
  // Post-wow: on the free lane and already got value (spent >= 1 run); offer the unlimited path they likely already own while they're happy, not when they're blocked.
  const freeTrialUsed = useAppSelector((s) => {
    const d = s.settings.data as any;
    if (!d || d.connection_mode !== 'free-trial' || !d.free_trial_token) return false;
    const limit = d.free_trial_runs_limit ?? 0;
    const remaining = d.free_trial_remaining ?? limit;
    return limit > 0 && (limit - remaining) >= 1;
  });
  const freeTrialResetsAt = useAppSelector((s) => (s.settings.data as any)?.free_trial_resets_at ?? null);
  // Coarse "~3h" / "~20m" label for when the rolling window refills; null when unknown or basically now. Static (not a ticking countdown) on purpose: a per-second timer is needless churn for a 5h window.
  const refillLabel = React.useMemo(() => {
    if (!freeTrialResetsAt) return null;
    const secs = freeTrialResetsAt - Date.now() / 1000;
    if (secs <= 90) return null;
    const h = Math.floor(secs / 3600);
    if (h >= 1) return `~${h}h`;
    return `~${Math.max(1, Math.round(secs / 60))}m`;
  }, [freeTrialResetsAt]);

  // Paid (openswarm-pro) usage meter: same calm "you're near/at the cap, here's when it's back" pattern as the free-trial nudge, but the bar IS the message. Only fires in pro mode on real server-owned usage (requests_in_window/plan_limit), and only once near the cap, so it never clutters the normal flow. window_ends_at is unix MS (the trial's resets_at is seconds).
  const proUsage = useAppSelector((s) => {
    const d = s.settings.data as any;
    if (!d || d.connection_mode !== 'openswarm-pro') return null;
    const u = d.openswarm_usage_cached;
    return u && u.plan_limit > 0 ? u : null;
  }, shallowEqual);
  const proPct = proUsage ? Math.min(1, proUsage.requests_in_window / proUsage.plan_limit) : 0;
  const proMaxed = !!proUsage && proPct >= 1;
  const showUsageNudge = isOnline && !!proUsage && proPct >= 0.8;
  const usageResetLabel = React.useMemo(() => {
    const endsAt = proUsage?.window_ends_at ?? 0;
    if (!endsAt) return null;
    const secs = (endsAt - Date.now()) / 1000;
    if (secs <= 90) return null;
    const h = Math.floor(secs / 3600);
    return h >= 1 ? `~${h}h` : `~${Math.max(1, Math.round(secs / 60))}m`;
  }, [proUsage]);
  // Hold the banner until the boot free-trial mint settles, else a brand-new user sees it flash red for the ~1-3s the trial takes to arm. (Offline shows immediately, it's its own signal.)
  const freeTrialArmSettled = useAppSelector((s) => s.settings.freeTrialArmSettled);
  // The red wall is for genuine "no way to run" only; the free-trial states get the quiet nudge below.
  const showWarningBanner = !isOnline || (modelsLoaded && freeTrialArmSettled && !hasModelConnected && !freeTrialActive && !freeTrialSpent);
  const [ftNudgeDismissed, setFtNudgeDismissed] = useState<boolean>(() => {
    try { return localStorage.getItem('os_ft_nudge_dismissed') === '1'; } catch { return false; }
  });
  // Spent nudge hides the moment they connect a real model; the post-wow nudge only shows on the trial lane (so it already implies no own model) and is dismissible.
  const showFreeTrialNudge = isOnline && ((freeTrialSpent && !hasModelConnected) || (freeTrialUsed && !ftNudgeDismissed));

  const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion;
  const isUpdateActionable = updateStatus === 'available' || updateStatus === 'downloaded' || updateStatus === 'downloading';

  const showUpdateDot = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion;
  const showUpdateBanner = isUpdateActionable && !bannerDismissedForVersion;
  const showUpdateSnackbar = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion && !snackbarDismissed;

  const handleDismissBanner = useCallback(() => {
    if (availableVersion) {
      try { localStorage.setItem(UPDATE_DISMISS_KEY, availableVersion); } catch {}
      setDismissedVersion(availableVersion);
    }
  }, [availableVersion]);

  const handleDownloadUpdate = useCallback(async () => {
    try { await (window as any).openswarm?.downloadUpdate(); } catch {}
  }, []);

  const handleInstallUpdate = useCallback(() => {
    if (installing) return;
    dispatch(setInstalling());
    (window as any).openswarm?.installUpdate();
  }, [installing, dispatch]);

  // shallowEqual on top-level Immer dicts: nested mutations bump the dict reference, causing AppShell to re-render on every rename/output bump despite identical structure.
  const dashboardItems = useAppSelector(
    (state) => state.dashboards.items,
    shallowEqual,
  );
  const dashboardList = React.useMemo(
    () => Object.values(dashboardItems).sort(byPreviewRecency),
    [dashboardItems],
  );

  const outputItems = useAppSelector(
    (state) => state.outputs.items,
    shallowEqual,
  );
  const appsList = React.useMemo(
    () => Object.values(outputItems).sort(byPreviewRecency),
    [outputItems],
  );

  useEffect(() => {
    dispatch(fetchDashboards());
    dispatch(fetchOutputs());
  }, [dispatch]);

  // Idle-prefetch the lazy Settings chunk so click-to-open is instant; requestIdleCallback avoids fighting first-paint.
  useEffect(() => {
    const ric = (window as any).requestIdleCallback || ((cb: () => void) => setTimeout(cb, 1500));
    const handle = ric(() => {
      import('@/app/pages/Settings/Settings').catch(() => {});
    }, { timeout: 3000 });
    return () => {
      const cic = (window as any).cancelIdleCallback || clearTimeout;
      try { cic(handle); } catch {}
    };
  }, []);

  const openUrlInBrowser = useCallback((url: string, webContentsId?: number, background?: boolean) => {
    const dashMatch = location.pathname.match(/^\/dashboard\/(.+)/);
    if (dashMatch) {
      if (webContentsId != null) {
        const browserId = findBrowserByWebContentsId(webContentsId);
        if (browserId) {
          // Middle-click / background-tab disposition: add the tab but don't steal focus from the current one, like a real browser.
          dispatch(addBrowserTab({ browserId, url, makeActive: !background }));
          return;
        }
      }
      dispatch(addBrowserCard({ url }));
    } else {
      dispatch(setPendingBrowserUrl(url));
      const lastId = (window as any).__openswarm_last_dashboard_id as string | undefined;
      const firstDashboard = dashboardList[0];
      // Only navigate to lastId if it's a REAL dashboard: a stale localStorage id for a deleted dashboard used to route to /dashboard/<phantom>, which 404s and re-fires the layout wipe (drops your cards / breaks a drag).
      const lastIsReal = !!lastId && dashboardList.some((d) => d.id === lastId);
      const targetId = (lastIsReal ? lastId : undefined) || firstDashboard?.id;
      if (targetId) {
        navigate(`/dashboard/${targetId}`);
      } else {
        dispatch(createDashboard('Untitled Dashboard')).then((result: any) => {
          if (createDashboard.fulfilled.match(result)) {
            navigate(`/dashboard/${result.payload.id}`);
          }
        });
      }
    }
  }, [location.pathname, dashboardList, dispatch, navigate]);

  useEffect(() => {
    let lastUrl = '';
    let lastTime = 0;

    const handleClick = (e: MouseEvent) => {
      const anchor = (e.target as HTMLElement)?.closest?.('a');
      if (!anchor) return;
      const href = anchor.getAttribute('href');
      if (!href) return;
      if (!/^https?:\/\//i.test(href)) return;
      if (href.startsWith('http://localhost:')) return;

      e.preventDefault();
      e.stopPropagation();

      const now = Date.now();
      if (href === lastUrl && now - lastTime < 1000) return;
      lastUrl = href;
      lastTime = now;

      openUrlInBrowser(href);
    };

    document.addEventListener('click', handleClick, true);
    return () => document.removeEventListener('click', handleClick, true);
  }, [openUrlInBrowser]);

  useEffect(() => {
    const w = window as any;
    if (!w.openswarm?.onWebviewNewWindow) return;
    let lastUrl = '';
    let lastTime = 0;
    return w.openswarm.onWebviewNewWindow((url: string, webContentsId: number, disposition?: string) => {
      const now = Date.now();
      if (url === lastUrl && now - lastTime < 1000) return;
      lastUrl = url;
      lastTime = now;
      openUrlInBrowser(url, webContentsId, disposition === 'background-tab');
    });
  }, [openUrlInBrowser]);

  // Track the browser card the user last touched. Chrome clicks land on this document; a webview PAGE click can't reach it, so BrowserCard reports those via the app-clicked IPC. Clearing on any non-browser-card click is what makes Ctrl+R fall back to reloading the app.
  useEffect(() => {
    const onPointerDown = (e: PointerEvent) => {
      const card = (e.target as HTMLElement | null)?.closest?.('[data-select-type="browser-card"]') as HTMLElement | null;
      if (card) setLastInteractedBrowser(card.getAttribute('data-select-id') || '');
      else clearLastInteractedBrowser();
    };
    document.addEventListener('pointerdown', onPointerDown, true);
    return () => document.removeEventListener('pointerdown', onPointerDown, true);
  }, []);

  // Cmd/Ctrl+R: main neutralizes the default-menu reload and hands us the decision. Reload the browser you're in or last used IN PLACE (keeps its login); only when no browser is open at all fall back to a full app reload, since reloading the renderer destroys every webview and wipes its session. To deliberately reload OpenSwarm itself, use View > Reload.
  useEffect(() => {
    const w = window as any;
    if (!w.openswarm?.onReloadShortcut) return;
    return w.openswarm.onReloadShortcut(() => {
      for (const id of [getLastInteractedBrowser(), ...getKeepAliveBrowserIds()]) {
        const wv = id ? getWebview(id) : undefined;
        if (wv) { try { wv.reload(); return; } catch (_e) { /* torn-down webview; try the next */ } }
      }
      window.location.reload();
    });
  }, []);

  // Zoom / find / tab-cycle from a focused browser GUEST (keydowns inside a webview can't reach this document, so main forwards them with the guest's id). Targets that exact browser; the host-focused counterparts live in the keydown below + useCanvasControls (zoom).
  useEffect(() => {
    const w = window as any;
    if (!w.openswarm?.onBrowserShortcut) return;
    return w.openswarm.onBrowserShortcut((payload: { action: string; webContentsId: number }) => {
      // Reopen-last-closed is global (no target browser), so handle it before the per-browser id guard.
      if (payload.action === 'reopen-closed') { dispatch(reopenLastClosed()); return; }
      const id = findBrowserByWebContentsId(payload.webContentsId) ?? getLastInteractedBrowser();
      if (!id) return;
      switch (payload.action) {
        case 'zoom-in': applyBrowserZoom(id, 1); break;
        case 'zoom-out': applyBrowserZoom(id, -1); break;
        case 'zoom-reset': applyBrowserZoom(id, 0); break;
        case 'find': window.dispatchEvent(new CustomEvent('openswarm:browser-find', { detail: { browserId: id } })); break;
        case 'tab-next': dispatch(cycleBrowserTab({ browserId: id, dir: 1 })); break;
        case 'tab-prev': dispatch(cycleBrowserTab({ browserId: id, dir: -1 })); break;
      }
    });
  }, [dispatch]);

  // Host-focused Ctrl/Cmd+F (find) and Ctrl+Tab (cycle) when a browser is the last thing you touched. Zoom keys aren't here: they share the +/-/0 keys with canvas zoom, so useCanvasControls owns that branch.
  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      const id = getLastInteractedBrowser();
      // Require a LIVE webview: a stale id (its card was closed) means no browser is focused, so let the canvas shortcuts (e.g. card-search Cmd+F) handle the key instead.
      if (!id || !getWebview(id)) return;
      const t = e.target as HTMLElement | null;
      const typing = t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || !!t?.isContentEditable;
      if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && (e.key || '').toLowerCase() === 'f' && !typing) {
        e.preventDefault();
        window.dispatchEvent(new CustomEvent('openswarm:browser-find', { detail: { browserId: id } }));
      } else if (e.ctrlKey && !e.metaKey && !e.altKey && e.key === 'Tab') {
        e.preventDefault();
        dispatch(cycleBrowserTab({ browserId: id, dir: e.shiftKey ? -1 : 1 }));
      }
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [dispatch]);

  useEffect(() => {
    try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {}
  }, [sidebarWidth]);

  useEffect(() => {
    const handler = (e: Event) => {
      const detail = (e as CustomEvent).detail || {};
      const { sessionId, dashboardId } = detail as { sessionId?: string; dashboardId?: string };
      if (!sessionId) return;
      if (dashboardId) {
        navigate(`/dashboard/${dashboardId}`);
      }
      dispatch(setPendingFocusAgentId(sessionId));
    };
    window.addEventListener('openswarm:notification-click', handler as EventListener);
    return () => window.removeEventListener('openswarm:notification-click', handler as EventListener);
  }, [navigate, dispatch]);

  const handleResizeStart = useCallback((e: React.MouseEvent) => {
    e.preventDefault();
    isResizing.current = true;
    document.body.style.cursor = 'col-resize';
    document.body.style.userSelect = 'none';

    const onMouseMove = (ev: MouseEvent) => {
      if (!isResizing.current) return;
      setSidebarWidth(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, ev.clientX)));
    };

    const onMouseUp = () => {
      isResizing.current = false;
      document.body.style.cursor = '';
      document.body.style.userSelect = '';
      document.removeEventListener('mousemove', onMouseMove);
      document.removeEventListener('mouseup', onMouseUp);
    };

    document.addEventListener('mousemove', onMouseMove);
    document.addEventListener('mouseup', onMouseUp);
  }, []);

  const handleResizeDoubleClick = useCallback(() => {
    setSidebarWidth(SIDEBAR_DEFAULT);
  }, []);

  const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard/');
  const isDashboardViewActive = location.pathname.startsWith('/dashboard/');
  // macOS full screen: a fullscreen-tiled card owns the window, so every shell chrome piece hides. Gated on the dashboard view so navigating away restores the chrome even mid-fullscreen.
  const fullscreenCardId = useAppSelector(selectFullscreenCardId);
  // Zen compact mode: the sidebar is the only chrome now, so whenever it's "away" (user collapsed it,
  // OR a fullscreen card hides everything) a left-edge hover floats it back in as an overlay.
  const fsActive = !!fullscreenCardId && isDashboardViewActive;
  const sidebarAway = (sidebarCollapsed || fsActive) && isDashboardViewActive;
  const [sidePeek, setSidePeek] = useState(false);
  useEffect(() => { if (!sidebarAway) setSidePeek(false); }, [sidebarAway]);
  // When the sidebar docks away, the canvas runs flush to the window's left edge, so the floating
  // dashboard header would sit right under the macOS traffic lights. Publish an inset the header reads
  // (only on macOS, where the lights exist) so it clears them; the sidebar carries its own clearance.
  useEffect(() => {
    const isMac = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform);
    const root = document.documentElement;
    if (sidebarAway && isMac) root.style.setProperty('--osw-header-inset', '80px');
    else root.style.removeProperty('--osw-header-inset');
    return () => { root.style.removeProperty('--osw-header-inset'); };
  }, [sidebarAway]);
  // Close-on-leave with a grace delay (cancelled on re-enter): a bare mouseLeave closed the peek the
  // instant the cursor dipped past the panel edge while reaching for an item, so clicks never landed.
  const peekCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const cancelPeekClose = useCallback(() => {
    if (peekCloseTimerRef.current) { clearTimeout(peekCloseTimerRef.current); peekCloseTimerRef.current = null; }
  }, []);
  const schedulePeekClose = useCallback(() => {
    cancelPeekClose();
    peekCloseTimerRef.current = setTimeout(() => setSidePeek(false), 260);
  }, [cancelPeekClose]);
  // Reliable window-level close: the panel's own mouseLeave can get eaten (webview/overlay capture),
  // leaving the peek stuck open. This fires on every move and closes ONLY when the cursor is clearly
  // to the right of the floating panel (generous 130px buffer so reaching for an item never closes it).
  useEffect(() => {
    if (!sidePeek) return undefined;
    const onMove = (e: MouseEvent): void => {
      if (e.clientX > 10 + sidebarWidth + 130) schedulePeekClose();
      else cancelPeekClose();
    };
    window.addEventListener('mousemove', onMove);
    return () => window.removeEventListener('mousemove', onMove);
  }, [sidePeek, sidebarWidth, schedulePeekClose, cancelPeekClose]);
  // Fullscreen still hides the top-center island anchor + banners; the sidebar floats in on peek.
  const fsHideChrome = fsActive;
  // In overlay mode the panel stays MOUNTED (so it can slide out, not vanish); sidePeek only drives the slide.
  const sideOverlay = sidebarAway;
  const isAppsRoute = false;  // /apps route removed; app cards live on the dashboard now.
  const activeDashboardId = location.pathname.startsWith('/dashboard/')
    ? location.pathname.split('/dashboard/')[1]
    : null;

  // Flip to the previous/next dashboard, clamped at the ends (no surprise wrap). Shared by the sidebar
  // swipe and the Cmd/Ctrl+Alt+arrow keyboard path.
  const switchDashboard = useCallback((dir: -1 | 1) => {
    if (dashboardList.length < 2) return;
    const idx = dashboardList.findIndex((d) => d.id === activeDashboardId);
    if (idx < 0) return;
    const next = Math.min(dashboardList.length - 1, Math.max(0, idx + dir));
    if (next !== idx) navigate(`/dashboard/${dashboardList[next].id}`);
  }, [dashboardList, activeDashboardId, navigate]);

  const handleSidebarSwipe = useCallback((e: React.WheelEvent) => {
    if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) { swipeAccumRef.current = 0; return; }  // vertical = list scroll
    if (swipeCooldownRef.current) return;
    swipeAccumRef.current += e.deltaX;
    if (Math.abs(swipeAccumRef.current) >= 80) {
      switchDashboard(swipeAccumRef.current > 0 ? 1 : -1);
      swipeAccumRef.current = 0;
      swipeCooldownRef.current = true;
      setTimeout(() => { swipeCooldownRef.current = false; }, 450);
    }
  }, [switchDashboard]);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (!(e.metaKey || e.ctrlKey) || !e.altKey || e.shiftKey) return;
      if (e.key === 'ArrowLeft') { e.preventDefault(); switchDashboard(-1); }
      else if (e.key === 'ArrowRight') { e.preventDefault(); switchDashboard(1); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [switchDashboard]);

  const [lastDashboardId, setLastDashboardId] = useLastDashboardId();
  // Apps no longer have a full-page editor; clicking one in the sidebar drops (or focuses) its live card on the current dashboard. Fold-in of the old App Builder.
  const navigateToApp = useCallback((id: string) => {
    dispatch(addViewCard({ outputId: id }));
    if (lastDashboardId && location.pathname !== `/dashboard/${lastDashboardId}`) {
      navigate(`/dashboard/${lastDashboardId}`);
    }
  }, [dispatch, navigate, lastDashboardId, location.pathname]);
  // The real delete, run only once Undo has lapsed: tear the live view card down cleanly (never rip a
  // webview GPU surface mid-composite), then delete the output for good.
  const commitDeleteApp = useCallback((id: string) => {
    void removeViewCardCleanly(id, dispatch);
    dispatch(deleteOutput(id));
  }, [dispatch]);

  // Arc-style delete: the row hides immediately and an Undo toast holds for 6s. Undo restores it;
  // silence commits it. A second delete flushes the first (only one pending at a time).
  const handleDeleteApp = useCallback((e: React.MouseEvent, id: string, name: string) => {
    e.stopPropagation();
    if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current);
    const prev = pendingDeleteRef.current;
    if (prev && prev.id !== id) commitDeleteApp(prev.id);
    const entry = { id, name };
    pendingDeleteRef.current = entry;
    setPendingDelete(entry);
    deleteTimerRef.current = setTimeout(() => {
      commitDeleteApp(id);
      pendingDeleteRef.current = null;
      setPendingDelete(null);
      deleteTimerRef.current = null;
    }, 6000);
  }, [commitDeleteApp]);

  const handleUndoDeleteApp = useCallback(() => {
    if (deleteTimerRef.current) { clearTimeout(deleteTimerRef.current); deleteTimerRef.current = null; }
    pendingDeleteRef.current = null;
    setPendingDelete(null);
  }, []);

  // Leaving the app while a delete is still pending shouldn't strand a half-deleted ghost: commit it.
  useEffect(() => () => {
    if (deleteTimerRef.current) { clearTimeout(deleteTimerRef.current); }
    if (pendingDeleteRef.current) commitDeleteApp(pendingDeleteRef.current.id);
    if (peekTimerRef.current) clearTimeout(peekTimerRef.current);
  }, [commitDeleteApp]);

  const handleStartAppRename = (id: string, currentName: string) => {
    setRenamingDashboardId(null);
    setRenamingAppId(id);
    setRenameValue(currentName);
  };

  const handleAppRenameSubmit = (id: string) => {
    const trimmed = renameValue.trim();
    const previousName = outputItems[id]?.name;
    if (trimmed && trimmed !== previousName) {
      dispatch(updateOutput({ id, name: trimmed }));
    }
    setRenamingAppId(null);
  };
  // With the /apps route gone, an app row is "active" when its card is open on the dashboard, not from the URL.
  const openViewCardOutputIds = useAppSelector((s) =>
    new Set(Object.values(s.dashboardLayout.viewCards).map((vc) => vc.output_id)),
  );

  const handleDashboardsClick = () => {
    if (isDashboardRoute && location.pathname === '/') {
      setDashboardsExpanded((prev) => !prev);
    } else {
      navigate('/');
      setDashboardsExpanded(true);
    }
  };

  const handleDashboardItemClick = (dashboardId: string) => {
    if (renamingDashboardId === dashboardId) return;
    navigate(`/dashboard/${dashboardId}`);
  };

  const handleStartDashboardRename = (id: string, currentName: string) => {
    setRenamingAppId(null);
    setRenamingDashboardId(id);
    setRenameValue(currentName);
  };

  const handleDashboardRenameSubmit = (id: string) => {
    const trimmed = renameValue.trim();
    const previousName = dashboardItems[id]?.name;
    if (trimmed && trimmed !== previousName) {
      dispatch(renameDashboard({ id, name: trimmed, previousName }));
    }
    setRenamingDashboardId(null);
  };

  const handleCreateDashboard = async (e: React.MouseEvent) => {
    e.stopPropagation();
    const result = await dispatch(createDashboard('Untitled Dashboard'));
    if (createDashboard.fulfilled.match(result)) {
      navigate(`/dashboard/${result.payload.id}`);
    }
  };

  const handleAppsClick = () => {
    setAppsExpanded((prev) => !prev);
  };

  return (
    <Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.secondary }}>
      {sidebarAway && !sidePeek && (
        <Box onMouseEnter={() => { cancelPeekClose(); setSidePeek(true); }} sx={{ position: 'fixed', top: 0, left: 0, bottom: 0, width: 14, zIndex: 2147483000, pointerEvents: 'auto' }} />
      )}
      {/* Top bar dropped (Arc/Zen): a zero-height anchor left only to float the agent-activity island at top-center; the island renders nothing when idle. */}
      <Box
        sx={{
          height: 0,
          flexShrink: 0,
          position: 'relative',
          overflow: 'visible',
          zIndex: 10,
          display: fsHideChrome ? 'none' : 'block',
        }}
      >
        <DynamicIsland />
      </Box>

      <Collapse in={showWarningBanner && !fsHideChrome} timeout={350} unmountOnExit>
        <Box
          sx={{
            display: 'flex',
            alignItems: 'center',
            gap: 1.5,
            px: 2,
            py: 0.6,
            bgcolor: 'rgba(239, 68, 68, 0.08)',
            borderBottom: '1px solid rgba(239, 68, 68, 0.18)',
            flexShrink: 0,
            animation: showWarningBanner ? 'warning-fade-in 0.4s ease-out' : undefined,
            '@keyframes warning-fade-in': {
              from: { opacity: 0 },
              to: { opacity: 1 },
            },
          }}
        >
          <ErrorSlime size={22} />
          <Typography sx={{ fontSize: '0.86rem', color: '#ef4444', flex: 1, fontWeight: 500, letterSpacing: '0.01em' }}>
            {!isOnline
              ? 'No internet connection; agents cannot reach AI models or external services'
              : (
                <>
                  No AI model connected.{' '}
                  <Box
                    component="span"
                    onClick={() => dispatch(openSettingsModal('models'))}
                    sx={{
                      textDecoration: 'underline',
                      cursor: 'pointer',
                      fontWeight: 600,
                      '&:hover': { opacity: 0.8 },
                      transition: 'opacity 0.15s',
                    }}
                  >
                    Configure models
                  </Box>
                  {' '}to get started
                </>
              )}
          </Typography>
        </Box>
      </Collapse>

      <Collapse in={showFreeTrialNudge && !fsHideChrome} timeout={300} unmountOnExit>
        <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 0.5, flexShrink: 0 }}>
          <Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, flex: 1, letterSpacing: '0.01em' }}>
            {freeTrialSpent
              ? (refillLabel ? `Out of free runs, fresh ones in ${refillLabel}. ` : "You're out of free runs for now. ")
              : "Nice, you're rolling. "}
            <Box
              component="span"
              onClick={() => dispatch(openSettingsModal('models'))}
              sx={{ color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
            >
              Connect the Claude or ChatGPT you already have
            </Box>
            {freeTrialSpent ? '.' : ' to keep going unlimited.'}
          </Typography>
          {!freeTrialSpent && (
            <Box
              role="button"
              aria-label="Dismiss"
              onClick={() => { try { localStorage.setItem('os_ft_nudge_dismissed', '1'); } catch {} setFtNudgeDismissed(true); }}
              sx={{ color: c.text.muted, cursor: 'pointer', fontSize: '0.95rem', lineHeight: 1, px: 0.5, '&:hover': { color: c.text.secondary } }}
            >
              ×
            </Box>
          )}
        </Box>
      </Collapse>

      <Collapse in={showUsageNudge && !fsHideChrome} timeout={300} unmountOnExit>
        <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 0.5, flexShrink: 0 }}>
          {/* the bar is the message: how full your Pro window is. calm accent, never red. */}
          <Box sx={{ width: 132, height: 5, borderRadius: 3, bgcolor: c.border.medium, overflow: 'hidden', flexShrink: 0 }}>
            <Box sx={{ width: `${Math.round(proPct * 100)}%`, height: '100%', bgcolor: c.accent.primary, transition: 'width 0.3s ease' }} />
          </Box>
          {usageResetLabel && (
            <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, color: c.text.secondary }}>
              <Clock size={12} style={{ flexShrink: 0 }} />
              <Typography sx={{ fontSize: '0.8rem', letterSpacing: '0.01em' }}>{usageResetLabel}</Typography>
            </Box>
          )}
          {proMaxed && (
            <Box
              component="span"
              onClick={() => dispatch(openSettingsModal('models'))}
              sx={{ color: c.accent.primary, cursor: 'pointer', fontSize: '0.8rem', '&:hover': { textDecoration: 'underline' } }}
            >
              Upgrade
            </Box>
          )}
        </Box>
      </Collapse>

      {showUpdateBanner && !fsHideChrome && (
        <Box
          sx={{
            display: 'flex',
            alignItems: 'center',
            gap: 1.5,
            px: 2,
            py: 0.5,
            bgcolor: `${c.accent.primary}14`,
            borderBottom: `1px solid ${c.accent.primary}30`,
            flexShrink: 0,
          }}
        >
          <SystemUpdateAltIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
          <Typography sx={{ fontSize: '0.8rem', color: c.text.secondary, flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
            {updateStatus === 'available' && `OpenSwarm${verSuffix} is available`}
            {updateStatus === 'downloading' && `Downloading OpenSwarm${verSuffix}…`}
            {updateStatus === 'downloaded' && `OpenSwarm${verSuffix} is ready to install`}
          </Typography>
          {updateStatus === 'downloading' && (
            <LinearProgress
              variant="determinate"
              value={downloadPercent}
              sx={{
                width: 120,
                height: 3,
                flexShrink: 0,
                borderRadius: 2,
                bgcolor: `${c.accent.primary}20`,
                '& .MuiLinearProgress-bar': { bgcolor: c.accent.primary, borderRadius: 2 },
              }}
            />
          )}
          {updateStatus === 'downloading' && (
            <Typography sx={{ fontSize: '0.72rem', color: c.text.tertiary, flexShrink: 0 }}>
              {Math.round(downloadPercent)}%
            </Typography>
          )}
          {updateStatus === 'available' && (
            <Button
              size="small"
              variant="contained"
              onClick={handleDownloadUpdate}
              sx={{
                bgcolor: c.accent.primary,
                '&:hover': { bgcolor: c.accent.pressed },
                textTransform: 'none',
                fontSize: '0.75rem',
                fontWeight: 600,
                borderRadius: 1.5,
                minWidth: 'auto',
                py: 0.25,
                px: 1.5,
                lineHeight: 1.5,
                flexShrink: 0,
              }}
            >
              Download
            </Button>
          )}
          {updateStatus === 'downloaded' && (
            <Button
              size="small"
              variant="contained"
              onClick={handleInstallUpdate}
              disabled={installing}
              startIcon={installing ? <CircularProgress size={12} sx={{ color: '#fff' }} /> : undefined}
              sx={{
                bgcolor: c.accent.primary,
                '&:hover': { bgcolor: c.accent.pressed },
                '&.Mui-disabled': { bgcolor: c.accent.primary, color: '#fff', opacity: 0.7 },
                textTransform: 'none',
                fontSize: '0.75rem',
                fontWeight: 600,
                borderRadius: 1.5,
                minWidth: 'auto',
                py: 0.25,
                px: 1.5,
                lineHeight: 1.5,
                flexShrink: 0,
              }}
            >
              {installing ? 'Restarting…' : 'Restart & Update'}
            </Button>
          )}
          <IconButton
            size="small"
            onClick={handleDismissBanner}
            sx={{ color: c.text.tertiary, p: 0.25, flexShrink: 0, '&:hover': { color: c.text.secondary } }}
          >
            <CloseIcon sx={{ fontSize: 14 }} />
          </IconButton>
        </Box>
      )}

      <Box sx={{ display: 'flex', flex: 1, minHeight: 0 }}>
      {((!sidebarCollapsed && !fsHideChrome) || sideOverlay) && (
      <>
      <Box
        onMouseEnter={() => { if (sideOverlay) cancelPeekClose(); }}
        onMouseLeave={() => { if (sideOverlay) schedulePeekClose(); }}
        onWheel={handleSidebarSwipe}
        sx={{
          width: sidebarWidth,
          flexShrink: 0,
          bgcolor: c.bg.secondary,
          display: 'flex',
          flexDirection: 'column',
          // Zen/Arc compact mode: a detached, rounded panel that SLIDES in and out from the left edge
          // (sidePeek drives the transform both ways; it stays mounted so leaving glides it away, not vanish).
          ...(sideOverlay ? {
            // Hug the window's top/left/bottom edges (same footprint as the docked sidebar) so the
            // dashboard's corner tint can't peek in the margins; only the dashboard-facing right corners
            // stay rounded. The OS clips the square outer corners to the window's own rounding, so the
            // window edge (the "last border") stays intact.
            position: 'fixed', top: 0, left: 0, bottom: 0, zIndex: 1000002,
            borderRadius: '0 14px 14px 0', overflow: 'hidden',
            boxShadow: '8px 0 32px rgba(0,0,0,0.28)', borderRight: `1px solid ${c.border.medium}`,
            transform: sidePeek ? 'translateX(0)' : 'translateX(-118%)',
            transition: 'transform 240ms cubic-bezier(0.22,1,0.36,1)',
            pointerEvents: sidePeek ? 'auto' : 'none',
          } : {}),
        }}
      >
        {/* Sidebar header = the app's chrome home (Arc/Zen): window-light clearance, back/forward, collapse, then the search command bar. */}
        <Box sx={{ pt: '30px', px: 1, pb: 0.75, display: 'flex', flexDirection: 'column', gap: 0.75, WebkitAppRegion: 'drag', flexShrink: 0 }}>
          <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, WebkitAppRegion: 'no-drag' }}>
            <Tooltip title="Back">
              <span>
                <IconButton size="small" onClick={() => navigate(-1)} disabled={!canGoBack}
                  sx={{ color: c.text.tertiary, p: 0.5, borderRadius: 1, '& svg': { transition: 'transform 0.2s cubic-bezier(0.34,1.56,0.64,1)' }, '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, '&:hover svg': { transform: 'translateX(-2px)' } }}>
                  <ArrowLeft size={17} />
                </IconButton>
              </span>
            </Tooltip>
            <Tooltip title="Forward">
              <span>
                <IconButton size="small" onClick={() => navigate(1)} disabled={!canGoForward}
                  sx={{ color: c.text.tertiary, p: 0.5, borderRadius: 1, '& svg': { transition: 'transform 0.2s cubic-bezier(0.34,1.56,0.64,1)' }, '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, '&:hover svg': { transform: 'translateX(2px)' } }}>
                  <ArrowRight size={17} />
                </IconButton>
              </span>
            </Tooltip>
            <Box sx={{ flex: 1 }} />
            <Tooltip title={sideOverlay ? 'Dock sidebar' : 'Hide sidebar'}>
              {/* Arc-style pin: from the floating peek this DOCKS the sidebar permanently (pushes the
                  canvas), from docked it collapses back to peek. Toggle, not one-way collapse. */}
              <IconButton size="small" onClick={() => setSidebarCollapsed((v) => !v)}
                data-onboarding="sidebar-toggle" aria-expanded={!sidebarCollapsed}
                sx={{ color: c.text.tertiary, p: 0.5, borderRadius: 1, '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` } }}>
                <AnimatedPanelLeft size={17} />
              </IconButton>
            </Tooltip>
          </Box>
          <ButtonBase
            onClick={() => window.dispatchEvent(new CustomEvent('openswarm:open-search'))}
            sx={{
              WebkitAppRegion: 'no-drag',
              display: 'flex', alignItems: 'center', gap: 1, width: '100%', justifyContent: 'flex-start',
              px: 1.25, py: 0.9, borderRadius: 2, border: `1px solid ${c.border.medium}`, bgcolor: c.bg.surface,
              color: c.text.tertiary, transition: 'border-color 0.15s, background 0.15s',
              '&:hover': { borderColor: c.border.strong, bgcolor: c.bg.page },
            }}
          >
            <SearchGlyph size={15} />
            <Typography sx={{ flex: 1, textAlign: 'left', fontSize: '0.86rem', color: c.text.muted }}>Search</Typography>
            <Typography sx={{ fontSize: '0.72rem', color: c.text.ghost, fontFamily: c.font.mono }}>{SEARCH_HOTKEY_LABEL}</Typography>
          </ButtonBase>
        </Box>
        <Box sx={{
          flex: 1,
          overflow: 'auto',
          pt: 0.5,
          '&::-webkit-scrollbar': { width: 0 },
          // Tactile hover: the leading section icon springs once on row-hover, then settles. Interaction-only, never ambient. Scoped to ListItemIcon so the +/chevron stay put.
          '& .MuiListItemIcon-root svg': {
            transition: 'transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
          },
          // Per-glyph hover choreography: each section icon reacts in its own way, springy then settles. Interaction-only, never ambient.
          '& [data-onboarding="sidebar-dashboards"]:hover .MuiListItemIcon-root svg': {
            transform: 'scale(1.14)',
          },
          '& [data-onboarding="sidebar-apps"]:hover .MuiListItemIcon-root svg': {
            transform: 'rotate(8deg) scale(1.08)',
          },
        }}>
          <Box sx={{ px: 1, mb: 0.25 }}>
            <ListItemButton
              onClick={handleDashboardsClick}
              data-onboarding="sidebar-dashboards"
              // Onboarding reads expanded so it skips the click step (re-click would collapse).
              data-expanded={dashboardsExpanded ? 'true' : 'false'}
              aria-expanded={dashboardsExpanded}
              sx={{
                borderRadius: 1.5,
                py: 0.6,
                px: 1.25,
                bgcolor: isDashboardRoute ? `${c.accent.primary}12` : 'transparent',
                '&:hover': { bgcolor: isDashboardRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
                transition: 'background-color 0.15s',
              }}
            >
              <ListItemIcon sx={{ color: isDashboardRoute ? c.accent.primary : c.text.tertiary, minWidth: 28 }}>
                <LayoutDashboard size={18} />
              </ListItemIcon>
              <ListItemText
                primary="Dashboards"
                sx={{
                  '& .MuiListItemText-primary': {
                    color: isDashboardRoute ? c.text.primary : c.text.muted,
                    fontSize: '0.9rem',
                    fontWeight: isDashboardRoute ? 600 : 400,
                  },
                }}
              />
              <Tooltip title="New dashboard" placement="right">
                <IconButton
                  size="small"
                  onClick={handleCreateDashboard}
                  sx={{
                    color: c.text.ghost,
                    p: 0.25,
                    mr: 0.25,
                    borderRadius: 1,
                    '&:hover': { color: c.accent.primary, bgcolor: `${c.accent.primary}14` },
                  }}
                >
                  <Plus size={15} />
                </IconButton>
              </Tooltip>
              {dashboardList.length > 0 && (
                <ExpandMoreIcon
                  sx={{
                    color: c.text.ghost,
                    fontSize: 16,
                    transition: 'transform 0.2s',
                    transform: dashboardsExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
                  }}
                />
              )}
            </ListItemButton>

            <Collapse in={dashboardsExpanded && dashboardList.length > 0} timeout={200}>
              <Box
                sx={{
                  ml: 2,
                  mt: 0.25,
                  mb: 0.5,
                  maxHeight: 240,
                  overflow: 'auto',
                  '&::-webkit-scrollbar': { width: 3 },
                  '&::-webkit-scrollbar-track': { background: 'transparent' },
                  '&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 4 },
                  scrollbarWidth: 'thin',
                  scrollbarColor: `${c.border.medium} transparent`,
                }}
              >
                {dashboardList.map((entry, idx) => {
                  const isActive = activeDashboardId === entry.id;
                  const isRenaming = renamingDashboardId === entry.id;
                  return (
                    <Box
                      key={entry.id}
                      // First row gets generic "first" alias so onboarding can teach "click into a dashboard" without a specific id.
                      data-onboarding={
                        idx === 0 ? 'dashboard-row-first' : `dashboard-row-${entry.id}`
                      }
                      onClick={() => handleDashboardItemClick(entry.id)}
                      sx={{
                        display: 'flex',
                        alignItems: 'center',
                        gap: 0.75,
                        pl: 1.25,
                        pr: 1,
                        py: isRenaming ? 0.25 : 0.5,
                        mx: 0.5,
                        cursor: isRenaming ? 'default' : 'pointer',
                        // Finder-style selection: the rounded fill is the one active cue, no rail marker.
                        borderRadius: `${c.radius.md}px`,
                        bgcolor: isActive ? `${c.accent.primary}40` : 'transparent',
                        '&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` },
                        transition: 'background-color 0.12s',
                      }}
                    >
                      {isRenaming ? (
                        <InputBase
                          autoFocus
                          value={renameValue}
                          onChange={(e) => setRenameValue(e.target.value)}
                          onBlur={() => handleDashboardRenameSubmit(entry.id)}
                          onKeyDown={(e) => {
                            if (e.key === 'Enter') handleDashboardRenameSubmit(entry.id);
                            if (e.key === 'Escape') setRenamingDashboardId(null);
                          }}
                          onClick={(e) => e.stopPropagation()}
                          onFocus={(e) => e.target.select()}
                          sx={{
                            flex: 1,
                            minWidth: 0,
                            fontSize: '0.86rem',
                            fontWeight: isActive ? 500 : 400,
                            color: isActive ? c.text.secondary : c.text.ghost,
                            py: 0,
                            px: 0.5,
                            borderRadius: 0.75,
                            border: `1px solid ${c.accent.primary}80`,
                            bgcolor: `${c.bg.page}`,
                            '& input': {
                              padding: '1px 0',
                            },
                          }}
                        />
                      ) : (
                        <Typography
                          onDoubleClick={(e) => {
                            e.stopPropagation();
                            handleStartDashboardRename(entry.id, entry.name);
                          }}
                          sx={{
                            color: isActive ? c.text.secondary : c.text.ghost,
                            fontSize: '0.86rem',
                            fontWeight: isActive ? 500 : 400,
                            overflow: 'hidden',
                            textOverflow: 'ellipsis',
                            whiteSpace: 'nowrap',
                            flex: 1,
                            minWidth: 0,
                          }}
                        >
                          {entry.name}
                        </Typography>
                      )}
                    </Box>
                  );
                })}
              </Box>
            </Collapse>
          </Box>

          {/* Sections separate with air, not lines. */}
          <Box sx={{ my: 0.75 }} />

          <Box sx={{ px: 1, mb: 0.25 }}>
            <ListItemButton
              onClick={handleAppsClick}
              onMouseEnter={() => {
                const fn = (window as any).__openswarmPrefetchRoute;
                if (typeof fn === 'function') fn('/apps');
              }}
              data-onboarding="sidebar-apps"
              sx={{
                borderRadius: 1.5,
                py: 0.6,
                px: 1.25,
                bgcolor: isAppsRoute ? `${c.accent.primary}12` : 'transparent',
                '&:hover': { bgcolor: isAppsRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
                transition: 'background-color 0.15s',
              }}
            >
              <ListItemIcon sx={{ color: isAppsRoute ? c.accent.primary : c.text.tertiary, minWidth: 28 }}>
                <LayoutGrid size={18} />
              </ListItemIcon>
              <ListItemText
                primary="Apps"
                sx={{
                  '& .MuiListItemText-primary': {
                    color: isAppsRoute ? c.text.primary : c.text.muted,
                    fontSize: '0.9rem',
                    fontWeight: isAppsRoute ? 600 : 400,
                  },
                }}
              />
              {appsList.length > 0 && (
                <ExpandMoreIcon
                  sx={{
                    color: c.text.ghost,
                    fontSize: 16,
                    transition: 'transform 0.2s',
                    transform: appsExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
                  }}
                />
              )}
            </ListItemButton>

            <Collapse in={appsExpanded && appsList.length > 0} timeout={200}>
              <Box
                sx={{
                  ml: 2,
                  mt: 0.25,
                  mb: 0.5,
                  maxHeight: 240,
                  overflow: 'auto',
                  '&::-webkit-scrollbar': { width: 3 },
                  '&::-webkit-scrollbar-track': { background: 'transparent' },
                  '&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 4 },
                  scrollbarWidth: 'thin',
                  scrollbarColor: `${c.border.medium} transparent`,
                }}
              >
                {appsList.map((app) => {
                  if (pendingDelete?.id === app.id) return null;  // hidden while its Undo toast is up
                  const isActive = openViewCardOutputIds.has(app.id);
                  const isRenamingApp = renamingAppId === app.id;
                  const appName = app.name || 'Untitled App';
                  return (
                    <Box
                      key={app.id}
                      onClick={() => { if (!isRenamingApp) navigateToApp(app.id); }}
                      onMouseEnter={(e) => {
                        if (isRenamingApp) return;
                        const top = e.currentTarget.getBoundingClientRect().top;
                        if (peekTimerRef.current) clearTimeout(peekTimerRef.current);
                        peekTimerRef.current = setTimeout(() => setPeek({ name: appName, description: app.description || '', thumbnail: app.thumbnail || null, top }), 350);
                      }}
                      onMouseLeave={() => {
                        if (peekTimerRef.current) { clearTimeout(peekTimerRef.current); peekTimerRef.current = null; }
                        setPeek(null);
                      }}
                      sx={{
                        display: 'flex',
                        alignItems: 'center',
                        gap: 0.75,
                        pl: 1.25,
                        pr: 0.5,
                        py: isRenamingApp ? 0.25 : 0.5,
                        mx: 0.5,
                        cursor: isRenamingApp ? 'default' : 'pointer',
                        borderRadius: `${c.radius.md}px`,
                        bgcolor: isActive ? `${c.accent.primary}40` : 'transparent',
                        '&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` },
                        '& .app-del-btn': { opacity: 0, transition: 'opacity 0.12s' },
                        '&:hover .app-del-btn': { opacity: 1 },
                        transition: 'background-color 0.12s',
                      }}
                    >
                      {isRenamingApp ? (
                        <InputBase
                          autoFocus
                          value={renameValue}
                          onChange={(e) => setRenameValue(e.target.value)}
                          onBlur={() => handleAppRenameSubmit(app.id)}
                          onKeyDown={(e) => {
                            if (e.key === 'Enter') handleAppRenameSubmit(app.id);
                            if (e.key === 'Escape') setRenamingAppId(null);
                          }}
                          onClick={(e) => e.stopPropagation()}
                          onFocus={(e) => e.target.select()}
                          sx={{
                            flex: 1,
                            minWidth: 0,
                            fontSize: '0.86rem',
                            fontWeight: isActive ? 500 : 400,
                            color: isActive ? c.text.secondary : c.text.ghost,
                            py: 0,
                            px: 0.5,
                            borderRadius: 0.75,
                            border: `1px solid ${c.accent.primary}80`,
                            bgcolor: `${c.bg.page}`,
                            '& input': { padding: '1px 0' },
                          }}
                        />
                      ) : (
                        <>
                          <Typewriter value={appName} enabled={!!app.name && app.name !== 'Untitled App'}>
                            {(t) => (
                              <Typography
                                onDoubleClick={(e) => { e.stopPropagation(); handleStartAppRename(app.id, appName); }}
                                sx={{
                                  color: isActive ? c.text.secondary : c.text.ghost,
                                  fontSize: '0.86rem',
                                  fontWeight: isActive ? 500 : 400,
                                  overflow: 'hidden',
                                  textOverflow: 'ellipsis',
                                  whiteSpace: 'nowrap',
                                  flex: 1,
                                  minWidth: 0,
                                }}
                              >
                                {t}
                              </Typography>
                            )}
                          </Typewriter>
                          <IconButton
                            className="app-del-btn"
                            size="small"
                            aria-label={`Delete ${appName}`}
                            onClick={(e) => handleDeleteApp(e, app.id, appName)}
                            sx={{ p: 0.25, flexShrink: 0, color: c.text.ghost, '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}18` } }}
                          >
                            <CloseGlyph size={13} />
                          </IconButton>
                        </>
                      )}
                    </Box>
                  );
                })}
              </Box>
            </Collapse>
          </Box>

        </Box>

        <Box
          sx={{
            px: 1,
            py: 1.25,
          }}
        >
          <ListItemButton
            onClick={() => dispatch(openSettingsModal())}
            data-onboarding="sidebar-settings-button"
            sx={{
              borderRadius: 1.5,
              py: 0.6,
              px: 1.25,
              '& .MuiListItemIcon-root svg': { transition: 'transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)' },
              '&:hover': { bgcolor: `${c.text.tertiary}0A` },
              '&:hover .MuiListItemIcon-root svg': { transform: 'rotate(90deg)' },
              transition: 'background-color 0.15s',
            }}
          >
            <ListItemIcon sx={{ color: c.text.tertiary, minWidth: 28, position: 'relative' }}>
              <LucideSettings size={18} />
              {showUpdateDot && (
                <Box
                  sx={{
                    position: 'absolute',
                    top: 2,
                    right: 10,
                    width: 7,
                    height: 7,
                    borderRadius: '50%',
                    bgcolor: c.accent.primary,
                    border: `1.5px solid ${c.bg.secondary}`,
                  }}
                />
              )}
            </ListItemIcon>
            <ListItemText
              primary="Settings"
              sx={{
                '& .MuiListItemText-primary': {
                  color: c.text.muted,
                  fontSize: '0.9rem',
                  fontWeight: 400,
                },
              }}
            />
          </ListItemButton>
        </Box>
      </Box>
      <Box
        onMouseDown={handleResizeStart}
        onDoubleClick={handleResizeDoubleClick}
        sx={{
          // Hide the resize seam when the sidebar is a floating overlay (nothing to resize against).
          display: sideOverlay ? 'none' : 'block',
          // 6px hit-target at -3px margin overlaps the seam so the drag region doesn't read as a visible empty strip.
          width: 6,
          marginLeft: '-3px',
          marginRight: '-3px',
          flexShrink: 0,
          cursor: 'col-resize',
          position: 'relative',
          zIndex: 10,
          '&::after': {
            content: '""',
            position: 'absolute',
            top: 0,
            bottom: 0,
            left: '50%',
            transform: 'translateX(-50%)',
            width: 2,
            bgcolor: 'transparent',
            transition: 'background-color 0.2s',
          },
          '&:hover::after': {
            bgcolor: c.border.strong,
          },
          '&:active::after': {
            bgcolor: `${c.accent.primary}40`,
          },
        }}
      />
      </>
      )}

      <Box sx={{
        flex: 1,
        overflow: 'hidden',
        bgcolor: c.bg.page,
        position: 'relative',
        // Float the content as a rounded inset panel ("column pill"): the chrome (bg.secondary) frames it, so there are no divider lines, just air + radius. Fullscreen drops the frame entirely.
        mt: fsHideChrome ? 0 : '6px',
        mr: fsHideChrome ? 0 : '6px',
        mb: fsHideChrome ? 0 : '6px',
        ml: fsHideChrome ? 0 : '6px',
        borderRadius: fsHideChrome ? 0 : '14px',
      }}>
        {/* Hidden (not unmounted) when the dashboard view is active so the persistent Dashboard layered above can take over. */}
        <Box
          sx={{
            position: 'absolute',
            inset: 0,
            visibility: isDashboardViewActive ? 'hidden' : 'visible',
            pointerEvents: isDashboardViewActive ? 'none' : 'auto',
          }}
        >
          <Outlet />
        </Box>

        {/* CSS-hidden on other routes so webviews + state survive nav. */}
        {lastDashboardId && (
          <DashboardHost visible={isDashboardViewActive}>
            <Dashboard dashboardId={lastDashboardId} isActive={isDashboardViewActive} />
          </DashboardHost>
        )}
      </Box>
      </Box>

      <React.Suspense fallback={null}>
        <Settings />
      </React.Suspense>

      <Snackbar
        open={showUpdateSnackbar}
        autoHideDuration={10000}
        onClose={() => setSnackbarDismissed(true)}
        anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
      >
        <Alert
          severity="info"
          icon={updateStatus === 'downloaded'
            ? <RestartAltIcon sx={{ fontSize: 18 }} />
            : <SystemUpdateAltIcon sx={{ fontSize: 18 }} />
          }
          action={
            <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
              <Button
                size="small"
                onClick={() => setSnackbarDismissed(true)}
                sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.8rem', minWidth: 'auto' }}
              >
                Dismiss
              </Button>
              {updateStatus === 'available' && (
                <Button
                  size="small"
                  variant="contained"
                  onClick={handleDownloadUpdate}
                  sx={{
                    bgcolor: c.accent.primary,
                    '&:hover': { bgcolor: c.accent.pressed },
                    textTransform: 'none',
                    fontSize: '0.8rem',
                    borderRadius: 1.5,
                    minWidth: 'auto',
                  }}
                >
                  Download
                </Button>
              )}
              {updateStatus === 'downloaded' && (
                <Button
                  size="small"
                  variant="contained"
                  onClick={handleInstallUpdate}
                  disabled={installing}
                  startIcon={installing ? <CircularProgress size={12} sx={{ color: '#fff' }} /> : undefined}
                  sx={{
                    bgcolor: c.accent.primary,
                    '&:hover': { bgcolor: c.accent.pressed },
                    '&.Mui-disabled': { bgcolor: c.accent.primary, color: '#fff', opacity: 0.7 },
                    textTransform: 'none',
                    fontSize: '0.8rem',
                    borderRadius: 1.5,
                    minWidth: 'auto',
                  }}
                >
                  {installing ? 'Restarting…' : 'Restart & Update'}
                </Button>
              )}
            </Box>
          }
          sx={{
            bgcolor: c.bg.surface,
            color: c.text.primary,
            border: `1px solid ${c.border.medium}`,
            boxShadow: c.shadow.md,
            '& .MuiAlert-icon': { color: c.accent.primary },
          }}
        >
          {updateStatus === 'available' && `OpenSwarm${verSuffix} is available`}
          {updateStatus === 'downloaded' && `OpenSwarm${verSuffix} downloaded; restart to update`}
        </Alert>
      </Snackbar>

      {/* Arc-style peek: a live-thumbnail preview beside the sidebar while hovering an app row. Non-interactive so it never steals the hover. */}
      {peek && !sidebarCollapsed && (
        <Box
          sx={{
            position: 'fixed',
            left: sidebarWidth + 8,
            top: Math.min(Math.max(peek.top - 8, 12), window.innerHeight - 232),
            width: 264,
            zIndex: 1400,
            pointerEvents: 'none',
            borderRadius: `${c.radius.lg}px`,
            overflow: 'hidden',
            bgcolor: c.bg.surface,
            border: `1px solid ${c.border.medium}`,
            boxShadow: c.shadow.lg,
            '@keyframes peekIn': { from: { opacity: 0, transform: 'translateX(-6px)' }, to: { opacity: 1, transform: 'none' } },
            animation: 'peekIn 0.14s ease-out',
          }}
        >
          {peek.thumbnail ? (
            <Box component="img" src={peek.thumbnail} alt="" sx={{ width: '100%', height: 150, objectFit: 'cover', objectPosition: 'top left', display: 'block' }} />
          ) : (
            <Box sx={{ width: '100%', height: 92, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${c.text.tertiary}0A`, color: c.text.ghost, fontSize: '0.78rem' }}>
              No preview yet
            </Box>
          )}
          <Box sx={{ p: 1.25 }}>
            <Typography sx={{ fontWeight: 600, fontSize: '0.9rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{peek.name}</Typography>
            {peek.description && (
              <Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mt: 0.5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
                {peek.description}
              </Typography>
            )}
          </Box>
        </Box>
      )}

      {/* Arc-style Undo for a just-deleted app. Open while the delete is pending; our own 6s timer commits it and closes this. */}
      <Snackbar
        open={!!pendingDelete}
        anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
      >
        <Alert
          severity="info"
          icon={false}
          action={
            <Button
              size="small"
              onClick={handleUndoDeleteApp}
              sx={{ color: c.accent.primary, textTransform: 'none', fontWeight: 700, fontSize: '0.8rem', minWidth: 'auto' }}
            >
              Undo
            </Button>
          }
          sx={{
            bgcolor: c.bg.surface,
            color: c.text.primary,
            border: `1px solid ${c.border.medium}`,
            boxShadow: c.shadow.md,
            '& .MuiAlert-action': { alignItems: 'center', pt: 0 },
          }}
        >
          {`Deleted "${pendingDelete?.name ?? ''}"`}
        </Alert>
      </Snackbar>
    </Box>
  );
};

export default AppShell;
