From b9b99c1fa86f50b480da430d0595e8dbb8b6c801 Mon Sep 17 00:00:00 2001 From: BigBodyCobain <43977454+BigBodyCobain@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:26:58 -0600 Subject: [PATCH] Replace Mesh Chat Dead Drop tab with stretchable Agent Shell panel. Anchors to the Mesh Chat box, stretches on tab enter, and supports user resize without changing the fixed left column width. Co-authored-by: Cursor --- .../components/MeshChat/AgentShellPanel.tsx | 281 ++++++++++++++++++ frontend/src/components/MeshChat/index.tsx | 43 ++- .../MeshChat/useMeshChatController.ts | 8 +- 3 files changed, 312 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/MeshChat/AgentShellPanel.tsx 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) => (