From a2b38498e5fa0d41ecebd29859405a0123686eb8 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 25 Mar 2026 22:21:51 +0000 Subject: [PATCH 1/2] feat(gastown): terminal fullscreen toggle (combined) Combine the changes from previously separate PRs: - Add double-click fullscreen toggle to terminal resize bar - Add fullscreen styles and prop for terminal container This combines the previously separate efforts for terminal fullscreen support. --- src/components/gastown/TerminalBar.module.css | 12 +++ src/components/gastown/TerminalBar.tsx | 84 ++++++++++++++++--- 2 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 src/components/gastown/TerminalBar.module.css diff --git a/src/components/gastown/TerminalBar.module.css b/src/components/gastown/TerminalBar.module.css new file mode 100644 index 0000000000..db2e57584b --- /dev/null +++ b/src/components/gastown/TerminalBar.module.css @@ -0,0 +1,12 @@ +.fullscreen { + position: fixed; + inset: 0; + z-index: 100; + padding: 0; + margin: 0; + overflow: hidden; +} + +.fullscreenTransition { + transition: all 0.3s ease-in-out; +} diff --git a/src/components/gastown/TerminalBar.tsx b/src/components/gastown/TerminalBar.tsx index 284758cdf8..b21774f76a 100644 --- a/src/components/gastown/TerminalBar.tsx +++ b/src/components/gastown/TerminalBar.tsx @@ -33,11 +33,14 @@ import { MessageCircle, } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; +import styles from './TerminalBar.module.css'; type TerminalBarProps = { townId: string; /** Override base path for org-scoped routes (e.g. /organizations/[id]/gastown/[townId]) */ basePath?: string; + /** Whether the terminal is in fullscreen mode */ + fullscreen?: boolean; }; /** @@ -45,7 +48,7 @@ type TerminalBarProps = { * Agent terminal tabs are opened/closed via TerminalBarContext. * Can be positioned at bottom/top/right/left with drag-to-resize. */ -export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarProps) { +export function TerminalBar({ townId, basePath: basePathOverride, fullscreen: propFullscreen }: TerminalBarProps) { const townBasePath = basePathOverride ?? `/gastown/${townId}`; const { state: sidebarState, isMobile } = useSidebar(); const { @@ -146,13 +149,61 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP const effectiveActiveId = activeTabId ?? 'mayor'; const activeTab = allTabs.find(t => t.id === effectiveActiveId) ?? allTabs[0]; + // ── Fullscreen state ──────────────────────────────────────────────── + const [localFullscreen, setLocalFullscreen] = useState(false); + const isFullscreen = propFullscreen || localFullscreen; + const previousSizeRef = useRef(size); + + const enterFullscreen = useCallback(() => { + previousSizeRef.current = size; + setLocalFullscreen(true); + }, [size]); + + const exitFullscreen = useCallback(() => { + setSize(previousSizeRef.current); + setLocalFullscreen(false); + }, [setSize]); + + const toggleFullscreen = useCallback(() => { + if (isFullscreen) { + exitFullscreen(); + } else { + enterFullscreen(); + } + }, [isFullscreen, enterFullscreen, exitFullscreen]); + + // Escape key exits fullscreen + useEffect(() => { + if (!isFullscreen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + exitFullscreen(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isFullscreen, exitFullscreen]); + // ── Resize drag logic ────────────────────────────────────────────── const isDragging = useRef(false); const startPos = useRef(0); const startSize = useRef(0); + const lastClickTime = useRef(0); const onResizePointerDown = useCallback( (e: React.PointerEvent) => { + // Prevent drag on double-click (detected by < 300ms between clicks) + const now = Date.now(); + if (now - lastClickTime.current < 300) { + return; + } + lastClickTime.current = now; + + if (isFullscreen) { + exitFullscreen(); + return; + } + e.preventDefault(); isDragging.current = true; startSize.current = size; @@ -184,9 +235,16 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP document.addEventListener('pointermove', onPointerMove); document.addEventListener('pointerup', onPointerUp); }, - [size, position, horizontal, setSize] + [size, position, horizontal, setSize, isFullscreen, exitFullscreen] ); + // Double-click handler for resize bar + const onResizeDoubleClick = useCallback(() => { + if (!collapsed) { + toggleFullscreen(); + } + }, [collapsed, toggleFullscreen]); + // ── Compute container styles ─────────────────────────────────────── const totalSize = collapsed ? COLLAPSED_SIZE : COLLAPSED_SIZE + size; @@ -270,14 +328,14 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP return (
{position === 'bottom' && ( <> {!collapsed && ( -
+
)} @@ -300,6 +358,7 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP size={size} townId={townId} alarmWs={alarmWs} + fullscreen={isFullscreen} /> )} @@ -312,6 +371,7 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP size={size} townId={townId} alarmWs={alarmWs} + fullscreen={isFullscreen} /> {!collapsed && ( -
+
)} @@ -335,7 +395,7 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP {position === 'right' && ( <> {!collapsed && ( -
+
)} @@ -358,6 +418,7 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP size={size} townId={townId} alarmWs={alarmWs} + fullscreen={isFullscreen} /> )} @@ -382,9 +443,10 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP size={size} townId={townId} alarmWs={alarmWs} + fullscreen={isFullscreen} /> {!collapsed && ( -
+
)} @@ -758,6 +820,7 @@ function TerminalContent({ size, townId, alarmWs, + fullscreen, }: { activeTab: TabDef; collapsed: boolean; @@ -765,6 +828,7 @@ function TerminalContent({ size: number; townId: string; alarmWs: AlarmWsResult; + fullscreen?: boolean; }) { if (collapsed) return null; @@ -776,8 +840,8 @@ function TerminalContent({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }} - style={horizontal ? { height: size } : { width: size }} - className={`overflow-hidden ${horizontal ? '' : 'h-full'}`} + style={fullscreen ? {} : (horizontal ? { height: size } : { width: size })} + className={`overflow-hidden ${horizontal ? '' : 'h-full'} ${fullscreen ? 'h-full' : ''}`} > {activeTab.kind === 'mayor' ? ( From 3aeaf8e8861f3e70d567ed943f3a1e99d2e9be8c Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Tue, 31 Mar 2026 03:25:02 +0000 Subject: [PATCH 2/2] fix(gastown): make fullscreen state purely local in TerminalBar Remove broken controlled/uncontrolled fullscreen pattern. No callers pass the fullscreen prop, so the prop-priority override was dead code that would have trapped the component in fullscreen if ever used. Fullscreen is now purely local state toggled via double-click/Escape. --- src/components/gastown/TerminalBar.tsx | 35 ++++++++++++++++++-------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/components/gastown/TerminalBar.tsx b/src/components/gastown/TerminalBar.tsx index b21774f76a..0756ce9325 100644 --- a/src/components/gastown/TerminalBar.tsx +++ b/src/components/gastown/TerminalBar.tsx @@ -39,8 +39,6 @@ type TerminalBarProps = { townId: string; /** Override base path for org-scoped routes (e.g. /organizations/[id]/gastown/[townId]) */ basePath?: string; - /** Whether the terminal is in fullscreen mode */ - fullscreen?: boolean; }; /** @@ -48,7 +46,7 @@ type TerminalBarProps = { * Agent terminal tabs are opened/closed via TerminalBarContext. * Can be positioned at bottom/top/right/left with drag-to-resize. */ -export function TerminalBar({ townId, basePath: basePathOverride, fullscreen: propFullscreen }: TerminalBarProps) { +export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarProps) { const townBasePath = basePathOverride ?? `/gastown/${townId}`; const { state: sidebarState, isMobile } = useSidebar(); const { @@ -149,9 +147,8 @@ export function TerminalBar({ townId, basePath: basePathOverride, fullscreen: pr const effectiveActiveId = activeTabId ?? 'mayor'; const activeTab = allTabs.find(t => t.id === effectiveActiveId) ?? allTabs[0]; - // ── Fullscreen state ──────────────────────────────────────────────── - const [localFullscreen, setLocalFullscreen] = useState(false); - const isFullscreen = propFullscreen || localFullscreen; + // ── Fullscreen state (purely local — toggled via double-click / Escape) ── + const [isFullscreen, setLocalFullscreen] = useState(false); const previousSizeRef = useRef(size); const enterFullscreen = useCallback(() => { @@ -335,7 +332,11 @@ export function TerminalBar({ townId, basePath: basePathOverride, fullscreen: pr {position === 'bottom' && ( <> {!collapsed && ( -
+
)} @@ -386,7 +387,11 @@ export function TerminalBar({ townId, basePath: basePathOverride, fullscreen: pr closeTab={closeTab} /> {!collapsed && ( -
+
)} @@ -395,7 +400,11 @@ export function TerminalBar({ townId, basePath: basePathOverride, fullscreen: pr {position === 'right' && ( <> {!collapsed && ( -
+
)} @@ -446,7 +455,11 @@ export function TerminalBar({ townId, basePath: basePathOverride, fullscreen: pr fullscreen={isFullscreen} /> {!collapsed && ( -
+
)} @@ -840,7 +853,7 @@ function TerminalContent({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }} - style={fullscreen ? {} : (horizontal ? { height: size } : { width: size })} + style={fullscreen ? {} : horizontal ? { height: size } : { width: size }} className={`overflow-hidden ${horizontal ? '' : 'h-full'} ${fullscreen ? 'h-full' : ''}`} > {activeTab.kind === 'mayor' ? (