diff --git a/frontend/src/components/MeshChat/AgentShellPanel.tsx b/frontend/src/components/MeshChat/AgentShellPanel.tsx new file mode 100644 index 0000000..cad1362 --- /dev/null +++ b/frontend/src/components/MeshChat/AgentShellPanel.tsx @@ -0,0 +1,281 @@ +'use client'; + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { GripHorizontal, Terminal } from 'lucide-react'; + +const STORAGE_KEY = 'sb_agent_shell_dims'; +const SHELL_FONT_PX = 14; +const MIN_SHELL_WIDTH = 300; +const MIN_SHELL_HEIGHT = 220; +const STRETCH_WIDTH_RATIO = 2.15; +const STRETCH_MIN_WIDTH = 520; + +type ShellSize = { w: number; h: number }; + +function readStoredSize(): ShellSize | null { + if (typeof window === 'undefined') return null; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as ShellSize; + if ( + typeof parsed?.w === 'number' && + typeof parsed?.h === 'number' && + parsed.w >= MIN_SHELL_WIDTH && + parsed.h >= MIN_SHELL_HEIGHT + ) { + return parsed; + } + } catch { + /* ignore */ + } + return null; +} + +function writeStoredSize(size: ShellSize) { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(size)); + } catch { + /* ignore */ + } +} + +function clampSize(size: ShellSize, anchorLeft: number): ShellSize { + const maxW = Math.max(MIN_SHELL_WIDTH, window.innerWidth - anchorLeft - 12); + const maxH = Math.max(MIN_SHELL_HEIGHT, window.innerHeight - 12); + return { + w: Math.min(Math.max(size.w, MIN_SHELL_WIDTH), maxW), + h: Math.min(Math.max(size.h, MIN_SHELL_HEIGHT), maxH), + }; +} + +function defaultStretchedSize(anchor: DOMRect): ShellSize { + const stretchedW = Math.max(anchor.width * STRETCH_WIDTH_RATIO, STRETCH_MIN_WIDTH); + return clampSize({ w: stretchedW, h: anchor.height }, anchor.left); +} + +type Props = { + anchorRef: React.RefObject; + active: boolean; +}; + +export default function AgentShellPanel({ anchorRef, active }: Props) { + const [mounted, setMounted] = useState(false); + const [anchorRect, setAnchorRect] = useState(null); + const [size, setSize] = useState({ w: STRETCH_MIN_WIDTH, h: 360 }); + const [pos, setPos] = useState({ x: 0, y: 0 }); + const [userResized, setUserResized] = useState(Boolean(readStoredSize())); + const resizeRef = useRef<{ + edge: 'e' | 's' | 'se'; + startX: number; + startY: number; + origW: number; + origH: number; + } | null>(null); + + useEffect(() => { + setMounted(true); + }, []); + + const measureAnchor = useCallback(() => { + const el = anchorRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setAnchorRect(rect); + setPos({ x: rect.left, y: rect.top }); + + if (!userResized) { + setSize(defaultStretchedSize(rect)); + return; + } + + const stored = readStoredSize(); + if (stored) { + setSize(clampSize(stored, rect.left)); + } else { + setSize(defaultStretchedSize(rect)); + } + }, [anchorRef, userResized]); + + useEffect(() => { + if (!active) return; + measureAnchor(); + + const el = anchorRef.current; + if (!el) return; + + const observer = new ResizeObserver(() => measureAnchor()); + observer.observe(el); + + const onWindowChange = () => measureAnchor(); + window.addEventListener('resize', onWindowChange); + window.addEventListener('scroll', onWindowChange, true); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', onWindowChange); + window.removeEventListener('scroll', onWindowChange, true); + }; + }, [active, anchorRef, measureAnchor]); + + useEffect(() => { + if (!active || userResized) return; + const el = anchorRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const base = { w: rect.width, h: rect.height }; + setSize(base); + setPos({ x: rect.left, y: rect.top }); + + const frame = window.requestAnimationFrame(() => { + setSize(defaultStretchedSize(rect)); + }); + return () => window.cancelAnimationFrame(frame); + }, [active, anchorRef, userResized]); + + const beginResize = (edge: 'e' | 's' | 'se') => (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + resizeRef.current = { + edge, + startX: event.clientX, + startY: event.clientY, + origW: size.w, + origH: size.h, + }; + + const onMove = (ev: MouseEvent) => { + if (!resizeRef.current) return; + const dx = ev.clientX - resizeRef.current.startX; + const dy = ev.clientY - resizeRef.current.startY; + const { edge: ed, origW, origH } = resizeRef.current; + const anchorLeft = anchorRef.current?.getBoundingClientRect().left ?? pos.x; + const next: ShellSize = { + w: ed === 's' ? origW : origW + dx, + h: ed === 'e' ? origH : origH + dy, + }; + setUserResized(true); + setSize(clampSize(next, anchorLeft)); + }; + + const onUp = () => { + resizeRef.current = null; + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + setSize((current) => { + writeStoredSize(current); + return current; + }); + }; + + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }; + + const snapToStretchedDefault = () => { + const el = anchorRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setUserResized(false); + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + /* ignore */ + } + setPos({ x: rect.left, y: rect.top }); + setSize(defaultStretchedSize(rect)); + }; + + if (!mounted || !active || !anchorRect) { + return ( +
+ +
AGENT SHELL
+
+ Expand Mesh Chat to open the local agent shell. +
+
+ ); + } + + const shell = ( +
+
+
+ + AGENT SHELL + + local CLI · user cwd + +
+
+ + +
+
+ +
+
ShadowBroker agent shell (PTY wiring next)
+
Working directory: set your own path in Settings.
+
$ openclaw
+
$ codex
+
$ gemini
+
+
+ +
+ Drag right/bottom edges to resize · {Math.round(size.w)}×{Math.round(size.h)}px · {SHELL_FONT_PX}px font +
+ +
+
+
+
+ ); + + return ( + <> +
+
SHELL ACTIVE
+
+ Panel stretched from Mesh Chat. Drag edges on the shell to resize. +
+
+ {createPortal(shell, document.body)} + + ); +} diff --git a/frontend/src/components/MeshChat/index.tsx b/frontend/src/components/MeshChat/index.tsx index 9532cc8..515e0f5 100644 --- a/frontend/src/components/MeshChat/index.tsx +++ b/frontend/src/components/MeshChat/index.tsx @@ -1,6 +1,7 @@ 'use client'; -import React from 'react'; +import React, { useRef } from 'react'; +import AgentShellPanel from './AgentShellPanel'; import { motion, AnimatePresence } from 'framer-motion'; import { Antenna, @@ -89,6 +90,7 @@ function describeGateCompatReason(reason: string, gateId: string): string { // NO direct trust-mutating imports — all mutations go through the hook. const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { + const panelBoxRef = useRef(null); const ctrl = useMeshChatController(props); const { // UI state @@ -398,6 +400,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { > {/* Single unified box — matches Data Layers panel skin */}
@@ -435,16 +438,15 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { { key: 'meshtastic' as Tab, label: 'MESH', icon: , badge: 0 }, { key: 'dms' as Tab, - label: 'DEAD DROP', - icon: , - badge: totalDmNotify, + label: 'AGENT SHELL', + icon: , + badge: 0, }, ].map((tab) => (