handleResizeMouseDown(e, "s")}
+ />
+
handleResizeMouseDown(e, "w")}
+ />
+
handleResizeMouseDown(e, "e")}
+ />
+ >
+ )}
+
+ );
+}
diff --git a/src/components/SerialConsole.tsx b/src/components/SerialConsole.tsx
new file mode 100644
index 0000000..d915af7
--- /dev/null
+++ b/src/components/SerialConsole.tsx
@@ -0,0 +1,306 @@
+import { useState, useCallback, useRef, useEffect, useMemo } from "react";
+import {
+ IconTerminal,
+ IconSend,
+ IconFilter,
+ IconClearAll,
+ IconX,
+ IconSettings,
+} from "@tabler/icons-react";
+import { useSerialPort } from "../hooks/useSerialPort";
+import type { SerialPortConfig } from "../hooks/useSerialPort";
+
+interface SerialConsoleProps {
+ consoleId: string;
+ onClose?: () => void;
+ isSnapped?: boolean;
+ onSnapOut?: () => void;
+}
+
+export function SerialConsole({
+ consoleId,
+ onClose,
+ isSnapped = false,
+ onSnapOut,
+}: SerialConsoleProps) {
+ const {
+ isConnected,
+ isConnecting,
+ error,
+ connect,
+ disconnect,
+ sendData,
+ receivedData,
+ clearData,
+ } = useSerialPort();
+
+ const [showConfig, setShowConfig] = useState(!isConnected);
+ const [baudRate, setBaudRate] = useState("115200");
+ const [inputText, setInputText] = useState("");
+ const [filterRegex, setFilterRegex] = useState("");
+ const [replacePattern, setReplacePattern] = useState("");
+ const [replaceWith, setReplaceWith] = useState("");
+ const [showFilters, setShowFilters] = useState(false);
+
+ const terminalRef = useRef
(null);
+
+ // Auto-scroll to bottom when new data arrives
+ useEffect(() => {
+ if (terminalRef.current) {
+ terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
+ }
+ }, [receivedData]);
+
+ const handleConnect = useCallback(async () => {
+ const config: SerialPortConfig = {
+ baudRate: parseInt(baudRate, 10),
+ };
+ await connect(config);
+ setShowConfig(false);
+ }, [connect, baudRate]);
+
+ const handleDisconnect = useCallback(async () => {
+ await disconnect();
+ setShowConfig(true);
+ }, [disconnect]);
+
+ const handleSend = useCallback(async () => {
+ if (!inputText.trim()) return;
+ try {
+ await sendData(inputText + "\n");
+ setInputText("");
+ } catch (err) {
+ console.error("Failed to send:", err);
+ }
+ }, [inputText, sendData]);
+
+ const handleKeyPress = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ },
+ [handleSend],
+ );
+
+ // Apply filtering and replacement
+ const processedData = useMemo(() => {
+ let lines = receivedData.split("\n");
+
+ // Apply regex filter
+ if (filterRegex.trim()) {
+ try {
+ const regex = new RegExp(filterRegex, "i");
+ lines = lines.filter((line) => regex.test(line));
+ } catch {
+ // Invalid regex, skip filtering
+ }
+ }
+
+ // Apply sed-style replacement
+ if (replacePattern.trim() && replaceWith !== undefined) {
+ try {
+ const regex = new RegExp(replacePattern, "g");
+ lines = lines.map((line) => line.replace(regex, replaceWith));
+ } catch {
+ // Invalid regex, skip replacement
+ }
+ }
+
+ return lines.join("\n");
+ }, [receivedData, filterRegex, replacePattern, replaceWith]);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ Console {consoleId}
+
+ {isConnected && (
+
+ )}
+
+
+
+
+ {isSnapped && onSnapOut && (
+
+ )}
+ {onClose && (
+
+ )}
+
+
+
+ {/* Filters Panel */}
+ {showFilters && (
+
+ )}
+
+ {/* Configuration Panel */}
+ {showConfig && (
+
+
+
+
+
+
+
+ Serial Port Configuration
+
+
+
+
+
+
+
+
+
+
+
+ {onClose && (
+
+ )}
+
+
+ {error && (
+
+ )}
+
+
+
+ )}
+
+ {/* Terminal Display */}
+ {!showConfig && isConnected && (
+ <>
+
+ {processedData}
+
+
+ {/* Input Area */}
+
+ setInputText(e.target.value)}
+ onKeyPress={handleKeyPress}
+ placeholder="Type message and press Enter..."
+ className="flex-1 input-field text-sm"
+ />
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/hooks/useSerialPort.ts b/src/hooks/useSerialPort.ts
new file mode 100644
index 0000000..7d13eab
--- /dev/null
+++ b/src/hooks/useSerialPort.ts
@@ -0,0 +1,175 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+
+export interface SerialPortConfig {
+ baudRate: number;
+ dataBits?: 7 | 8;
+ stopBits?: 1 | 2;
+ parity?: "none" | "even" | "odd";
+ flowControl?: "none" | "hardware";
+}
+
+export interface UseSerialPortReturn {
+ port: SerialPort | null;
+ isConnected: boolean;
+ isConnecting: boolean;
+ error: string | null;
+ connect: (config: SerialPortConfig) => Promise;
+ disconnect: () => Promise;
+ sendData: (data: string) => Promise;
+ receivedData: string;
+ clearData: () => void;
+}
+
+export function useSerialPort(): UseSerialPortReturn {
+ const [port, setPort] = useState(null);
+ const [isConnected, setIsConnected] = useState(false);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [error, setError] = useState(null);
+ const [receivedData, setReceivedData] = useState("");
+
+ const readerRef = useRef | null>(
+ null,
+ );
+ const writerRef = useRef | null>(
+ null,
+ );
+ const readLoopRef = useRef(false);
+
+ const connect = useCallback(async (config: SerialPortConfig) => {
+ try {
+ setIsConnecting(true);
+ setError(null);
+
+ // Request a port
+ const selectedPort = await navigator.serial.requestPort();
+
+ // Open the port with the specified configuration
+ await selectedPort.open({
+ baudRate: config.baudRate,
+ dataBits: config.dataBits ?? 8,
+ stopBits: config.stopBits ?? 1,
+ parity: config.parity ?? "none",
+ flowControl: config.flowControl ?? "none",
+ });
+
+ setPort(selectedPort);
+ setIsConnected(true);
+
+ // Set up the writer
+ if (selectedPort.writable) {
+ writerRef.current = selectedPort.writable.getWriter();
+ }
+
+ // Set up the reader
+ if (selectedPort.readable) {
+ const reader = selectedPort.readable.getReader();
+ readerRef.current = reader;
+ readLoopRef.current = true;
+
+ // Start reading loop
+ (async () => {
+ const decoder = new TextDecoder();
+ try {
+ while (readLoopRef.current) {
+ const { value, done } = await reader.read();
+ if (done) {
+ break;
+ }
+ if (value) {
+ const text = decoder.decode(value, { stream: true });
+ setReceivedData((prev) => prev + text);
+ }
+ }
+ } catch (err) {
+ if (err instanceof Error && err.name !== "NetworkError") {
+ console.error("Serial read error:", err);
+ setError(err.message);
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ })();
+ }
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : "Failed to connect";
+ setError(errorMsg);
+ setIsConnected(false);
+ } finally {
+ setIsConnecting(false);
+ }
+ }, []);
+
+ const disconnect = useCallback(async () => {
+ try {
+ // Stop the read loop
+ readLoopRef.current = false;
+
+ // Release the reader
+ if (readerRef.current) {
+ try {
+ await readerRef.current.cancel();
+ } catch (err) {
+ console.error("Error canceling reader:", err);
+ }
+ readerRef.current = null;
+ }
+
+ // Release the writer
+ if (writerRef.current) {
+ try {
+ await writerRef.current.close();
+ } catch (err) {
+ console.error("Error closing writer:", err);
+ }
+ writerRef.current = null;
+ }
+
+ // Close the port
+ if (port) {
+ await port.close();
+ setPort(null);
+ }
+
+ setIsConnected(false);
+ } catch (err) {
+ const errorMsg =
+ err instanceof Error ? err.message : "Failed to disconnect";
+ setError(errorMsg);
+ }
+ }, [port]);
+
+ const sendData = useCallback(async (data: string) => {
+ if (!writerRef.current) {
+ throw new Error("Port not open for writing");
+ }
+
+ const encoder = new TextEncoder();
+ const encoded = encoder.encode(data);
+ await writerRef.current.write(encoded);
+ }, []);
+
+ const clearData = useCallback(() => {
+ setReceivedData("");
+ }, []);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (readerRef.current || writerRef.current || port) {
+ disconnect();
+ }
+ };
+ }, [disconnect, port]);
+
+ return {
+ port,
+ isConnected,
+ isConnecting,
+ error,
+ connect,
+ disconnect,
+ sendData,
+ receivedData,
+ clearData,
+ };
+}
diff --git a/src/pages/ConsolePage.tsx b/src/pages/ConsolePage.tsx
new file mode 100644
index 0000000..53ff069
--- /dev/null
+++ b/src/pages/ConsolePage.tsx
@@ -0,0 +1,403 @@
+import { useState, useCallback, useRef } from "react";
+import { IconTerminal2, IconPlus } from "@tabler/icons-react";
+import { SerialConsole } from "../components/SerialConsole";
+import { DraggableWindow } from "../components/DraggableWindow";
+import type { WindowPosition } from "../components/DraggableWindow";
+
+interface ConsoleWindow {
+ id: string;
+ position: WindowPosition;
+ zIndex: number;
+ snapPosition?: "left" | "right" | "top" | "bottom" | null;
+}
+
+type SnapPosition = "left" | "right" | "top" | "bottom" | null;
+
+export function ConsolePage() {
+ const [consoles, setConsoles] = useState([]);
+ const [maxZIndex, setMaxZIndex] = useState(1000);
+ const [draggedConsoleId, setDraggedConsoleId] = useState(null);
+ const [snapPreview, setSnapPreview] = useState(null);
+ const nextIdRef = useRef(1);
+ const containerRef = useRef(null);
+
+ const addConsole = useCallback(() => {
+ const id = `console-${nextIdRef.current++}`;
+ const newConsole: ConsoleWindow = {
+ id,
+ position: {
+ x: 100 + ((consoles.length * 30) % 200),
+ y: 100 + ((consoles.length * 30) % 200),
+ width: 600,
+ height: 400,
+ },
+ zIndex: maxZIndex + 1,
+ snapPosition: null,
+ };
+ setConsoles((prev) => [...prev, newConsole]);
+ setMaxZIndex((prev) => prev + 1);
+ }, [consoles.length, maxZIndex]);
+
+ const removeConsole = useCallback((id: string) => {
+ setConsoles((prev) => prev.filter((c) => c.id !== id));
+ }, []);
+
+ const bringToFront = useCallback(
+ (id: string) => {
+ setConsoles((prev) =>
+ prev.map((c) => (c.id === id ? { ...c, zIndex: maxZIndex + 1 } : c)),
+ );
+ setMaxZIndex((prev) => prev + 1);
+ },
+ [maxZIndex],
+ );
+
+ const updatePosition = useCallback((id: string, position: WindowPosition) => {
+ setConsoles((prev) =>
+ prev.map((c) => (c.id === id ? { ...c, position } : c)),
+ );
+ }, []);
+
+ const snapConsole = useCallback((id: string, snapPos: SnapPosition) => {
+ if (!snapPos || !containerRef.current) return;
+
+ const rect = containerRef.current.getBoundingClientRect();
+
+ setConsoles((prev) => {
+ const consolesToSnap = prev.filter(
+ (c) => c.snapPosition === snapPos || c.id === id,
+ );
+ const othersSnapped = consolesToSnap.filter((c) => c.id !== id);
+ const totalInSnap = othersSnapped.length + 1;
+
+ let newPosition: WindowPosition;
+
+ if (snapPos === "left" || snapPos === "right") {
+ const width = rect.width / 2 / totalInSnap;
+ const height = rect.height;
+ const index = othersSnapped.length;
+ newPosition = {
+ x:
+ snapPos === "left" ? index * width : rect.width / 2 + index * width,
+ y: 0,
+ width,
+ height,
+ };
+ } else {
+ const width = rect.width;
+ const height = rect.height / 2 / totalInSnap;
+ const index = othersSnapped.length;
+ newPosition = {
+ x: 0,
+ y:
+ snapPos === "top"
+ ? index * height
+ : rect.height / 2 + index * height,
+ width,
+ height,
+ };
+ }
+
+ // Update positions of all consoles in this snap area
+ const updated = prev.map((c) => {
+ if (c.id === id) {
+ return { ...c, position: newPosition, snapPosition: snapPos };
+ }
+ if (c.snapPosition === snapPos) {
+ // Recalculate positions for existing snapped consoles
+ const consoleIndex = othersSnapped.findIndex((oc) => oc.id === c.id);
+ if (consoleIndex >= 0) {
+ if (snapPos === "left" || snapPos === "right") {
+ const width = rect.width / 2 / totalInSnap;
+ const height = rect.height;
+ return {
+ ...c,
+ position: {
+ x:
+ snapPos === "left"
+ ? consoleIndex * width
+ : rect.width / 2 + consoleIndex * width,
+ y: 0,
+ width,
+ height,
+ },
+ };
+ } else {
+ const width = rect.width;
+ const height = rect.height / 2 / totalInSnap;
+ return {
+ ...c,
+ position: {
+ x: 0,
+ y:
+ snapPos === "top"
+ ? consoleIndex * height
+ : rect.height / 2 + consoleIndex * height,
+ width,
+ height,
+ },
+ };
+ }
+ }
+ }
+ return c;
+ });
+
+ return updated;
+ });
+ }, []);
+
+ const handleDragEnd = useCallback(
+ (id: string, position: WindowPosition) => {
+ if (!containerRef.current) return;
+
+ const rect = containerRef.current.getBoundingClientRect();
+ const snapThreshold = 50;
+
+ let snapPos: SnapPosition = null;
+
+ // Check if dragged to snap areas
+ if (position.x < snapThreshold) {
+ snapPos = "left";
+ } else if (position.x + position.width > rect.width - snapThreshold) {
+ snapPos = "right";
+ } else if (position.y < snapThreshold) {
+ snapPos = "top";
+ } else if (position.y + position.height > rect.height - snapThreshold) {
+ snapPos = "bottom";
+ }
+
+ if (snapPos) {
+ snapConsole(id, snapPos);
+ }
+
+ setDraggedConsoleId(null);
+ setSnapPreview(null);
+ },
+ [snapConsole],
+ );
+
+ const snapOut = useCallback((id: string) => {
+ setConsoles((prev) => {
+ const console = prev.find((c) => c.id === id);
+ if (!console || !console.snapPosition) return prev;
+
+ const snapPos = console.snapPosition;
+
+ // Move to center with default size
+ const newPosition: WindowPosition = {
+ x: 100,
+ y: 100,
+ width: 600,
+ height: 400,
+ };
+
+ const updated = prev.map((c) =>
+ c.id === id ? { ...c, position: newPosition, snapPosition: null } : c,
+ );
+
+ // Recalculate positions for remaining consoles in the snap area
+ if (containerRef.current) {
+ const rect = containerRef.current.getBoundingClientRect();
+ const remaining = updated.filter((c) => c.snapPosition === snapPos);
+
+ if (remaining.length > 0) {
+ return updated.map((c) => {
+ if (c.snapPosition === snapPos) {
+ const index = remaining.findIndex((rc) => rc.id === c.id);
+ if (index >= 0) {
+ if (snapPos === "left" || snapPos === "right") {
+ const width = rect.width / 2 / remaining.length;
+ const height = rect.height;
+ return {
+ ...c,
+ position: {
+ x:
+ snapPos === "left"
+ ? index * width
+ : rect.width / 2 + index * width,
+ y: 0,
+ width,
+ height,
+ },
+ };
+ } else {
+ const width = rect.width;
+ const height = rect.height / 2 / remaining.length;
+ return {
+ ...c,
+ position: {
+ x: 0,
+ y:
+ snapPos === "top"
+ ? index * height
+ : rect.height / 2 + index * height,
+ width,
+ height,
+ },
+ };
+ }
+ }
+ }
+ return c;
+ });
+ }
+ }
+
+ return updated;
+ });
+ }, []);
+
+ const handleDragStart = useCallback((id: string) => {
+ setDraggedConsoleId(id);
+ }, []);
+
+ const handlePositionChange = useCallback(
+ (id: string, position: WindowPosition) => {
+ if (!containerRef.current) return;
+
+ const rect = containerRef.current.getBoundingClientRect();
+ const snapThreshold = 50;
+
+ let snapPos: SnapPosition = null;
+
+ if (position.x < snapThreshold) {
+ snapPos = "left";
+ } else if (position.x + position.width > rect.width - snapThreshold) {
+ snapPos = "right";
+ } else if (position.y < snapThreshold) {
+ snapPos = "top";
+ } else if (position.y + position.height > rect.height - snapThreshold) {
+ snapPos = "bottom";
+ }
+
+ setSnapPreview(snapPos);
+ updatePosition(id, position);
+ },
+ [updatePosition],
+ );
+
+ const snappedConsoles = consoles.filter((c) => c.snapPosition !== null);
+ const floatingConsoles = consoles.filter((c) => c.snapPosition === null);
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+ Serial Console
+
+
+ Connect and monitor multiple serial devices
+
+
+
+
+
+
+ {/* Console Container */}
+
+ {/* Snap Preview Overlays */}
+ {draggedConsoleId && snapPreview && (
+
+ )}
+
+ {/* Snapped Consoles */}
+ {snappedConsoles.map((console) => (
+
+ removeConsole(console.id)}
+ isSnapped={true}
+ onSnapOut={() => snapOut(console.id)}
+ />
+
+ ))}
+
+ {/* Floating Windows */}
+ {floatingConsoles.map((console) => (
+
handlePositionChange(console.id, pos)}
+ onDragStart={() => handleDragStart(console.id)}
+ onDragEnd={(pos) => handleDragEnd(console.id, pos)}
+ zIndex={console.zIndex}
+ onFocus={() => bringToFront(console.id)}
+ >
+ removeConsole(console.id)}
+ />
+
+ ))}
+
+ {/* Empty State */}
+ {consoles.length === 0 && (
+
+
+
+
+ No console connections
+
+
+
+
+ )}
+
+
+ {/* Info Box */}
+
+
+ Drag console windows to the edges to snap them into place. Multiple
+ consoles can share the same snap area.
+
+
+
+
+ );
+}
From d574caadddd4eb694b2e7270bdebb56d60b1966e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 4 Feb 2026 16:01:27 +0000
Subject: [PATCH 4/8] Add ConsoleContext for global console state management
- Created ConsoleContext to manage console windows globally
- Implemented tab state persistence for snap positions
- Updated App.tsx to handle tab transitions
- Updated ConsolePage to use context
- Added automatic snap state restoration when switching tabs
Co-authored-by: cormoran <7994064+cormoran@users.noreply.github.com>
---
src/App.tsx | 30 ++-
src/contexts/ConsoleContext.tsx | 176 +++++++++++++++++
src/pages/ConsolePage.tsx | 339 ++++++++++++++++----------------
3 files changed, 375 insertions(+), 170 deletions(-)
create mode 100644 src/contexts/ConsoleContext.tsx
diff --git a/src/App.tsx b/src/App.tsx
index a4abb7f..0e28880 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,4 @@
-import { useState, useContext } from "react";
+import { useState, useContext, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
IconBattery2,
@@ -15,7 +15,9 @@ import {
DeviceConnectionProvider,
ConnectionContext,
} from "./components/DeviceConnection";
+import { ConsoleContext } from "./contexts/ConsoleContext";
import { ThemeProvider } from "./contexts/ThemeContext";
+import { ConsoleProvider } from "./contexts/ConsoleContext";
import { TabNavigation } from "./components/TabNavigation";
import type { TabItem } from "./components/TabNavigation";
import { AppLayout } from "./layouts/AppLayout";
@@ -75,17 +77,35 @@ const tabs: TabItem[] = [
function App() {
return (
-
-
-
+
+
+
+
+
);
}
function AppContent() {
const connection = useContext(ConnectionContext);
+ const consoleContext = useContext(ConsoleContext);
const [activeTab, setActiveTab] = useState("battery");
+ const handleTabChange = useCallback(
+ (tabId: string) => {
+ // Handle console tab transitions
+ if (activeTab === "console" && tabId !== "console") {
+ // Leaving console tab - convert to window mode
+ consoleContext?.exitToWindowMode();
+ } else if (activeTab !== "console" && tabId === "console") {
+ // Entering console tab - restore snap state
+ consoleContext?.restoreSnapState();
+ }
+ setActiveTab(tabId);
+ },
+ [activeTab, consoleContext],
+ );
+
return (
<>
@@ -122,7 +142,7 @@ function AppContent() {
diff --git a/src/contexts/ConsoleContext.tsx b/src/contexts/ConsoleContext.tsx
new file mode 100644
index 0000000..375c05d
--- /dev/null
+++ b/src/contexts/ConsoleContext.tsx
@@ -0,0 +1,176 @@
+import { createContext, useState, useCallback, useRef, ReactNode } from "react";
+import type { WindowPosition } from "../components/DraggableWindow";
+
+export interface ConsoleWindow {
+ id: string;
+ position: WindowPosition;
+ zIndex: number;
+ snapPosition?: "left" | "right" | "top" | "bottom" | null;
+ port?: SerialPort;
+}
+
+interface ConsoleContextValue {
+ consoles: ConsoleWindow[];
+ maxZIndex: number;
+ addConsole: () => void;
+ removeConsole: (id: string) => void;
+ updateConsole: (id: string, updates: Partial) => void;
+ bringToFront: (id: string) => void;
+ snapConsole: (
+ id: string,
+ snapPosition: "left" | "right" | "top" | "bottom",
+ ) => void;
+ snapOut: (id: string) => void;
+ restoreSnapState: () => void;
+ exitToWindowMode: () => void;
+}
+
+const ConsoleContext = createContext(null);
+
+interface ConsoleProviderProps {
+ children: ReactNode;
+}
+
+export function ConsoleProvider({ children }: ConsoleProviderProps) {
+ const [consoles, setConsoles] = useState([]);
+ const [maxZIndex, setMaxZIndex] = useState(1000);
+ const nextIdRef = useRef(1);
+ const savedSnapStatesRef = useRef<
+ Map
+ >(new Map());
+
+ const addConsole = useCallback(() => {
+ const id = `console-${nextIdRef.current++}`;
+ const newConsole: ConsoleWindow = {
+ id,
+ position: {
+ x: 100 + ((consoles.length * 30) % 200),
+ y: 100 + ((consoles.length * 30) % 200),
+ width: 600,
+ height: 400,
+ },
+ zIndex: maxZIndex + 1,
+ snapPosition: null,
+ };
+ setConsoles((prev) => [...prev, newConsole]);
+ setMaxZIndex((prev) => prev + 1);
+ }, [consoles.length, maxZIndex]);
+
+ const removeConsole = useCallback((id: string) => {
+ setConsoles((prev) => prev.filter((c) => c.id !== id));
+ savedSnapStatesRef.current.delete(id);
+ }, []);
+
+ const updateConsole = useCallback(
+ (id: string, updates: Partial) => {
+ setConsoles((prev) =>
+ prev.map((c) => (c.id === id ? { ...c, ...updates } : c)),
+ );
+ },
+ [],
+ );
+
+ const bringToFront = useCallback(
+ (id: string) => {
+ setConsoles((prev) =>
+ prev.map((c) => (c.id === id ? { ...c, zIndex: maxZIndex + 1 } : c)),
+ );
+ setMaxZIndex((prev) => prev + 1);
+ },
+ [maxZIndex],
+ );
+
+ const snapConsole = useCallback(
+ (id: string, snapPosition: "left" | "right" | "top" | "bottom") => {
+ setConsoles((prev) => {
+ const console = prev.find((c) => c.id === id);
+ if (!console) return prev;
+
+ // Save snap state
+ savedSnapStatesRef.current.set(id, snapPosition);
+
+ return prev.map((c) => (c.id === id ? { ...c, snapPosition } : c));
+ });
+ },
+ [],
+ );
+
+ const snapOut = useCallback((id: string) => {
+ setConsoles((prev) => {
+ const console = prev.find((c) => c.id === id);
+ if (!console) return prev;
+
+ // Move to center with default size
+ const newPosition: WindowPosition = {
+ x: 100,
+ y: 100,
+ width: 600,
+ height: 400,
+ };
+
+ return prev.map((c) =>
+ c.id === id ? { ...c, position: newPosition, snapPosition: null } : c,
+ );
+ });
+ }, []);
+
+ const exitToWindowMode = useCallback(() => {
+ // Save current snap states
+ consoles.forEach((console) => {
+ if (console.snapPosition) {
+ savedSnapStatesRef.current.set(console.id, console.snapPosition);
+ }
+ });
+
+ // Convert all snapped consoles to window mode
+ setConsoles((prev) =>
+ prev.map((c) => {
+ if (c.snapPosition) {
+ return {
+ ...c,
+ position: {
+ x: 100 + Math.random() * 100,
+ y: 100 + Math.random() * 100,
+ width: 600,
+ height: 400,
+ },
+ snapPosition: null,
+ };
+ }
+ return c;
+ }),
+ );
+ }, [consoles]);
+
+ const restoreSnapState = useCallback(() => {
+ // Restore saved snap states
+ setConsoles((prev) =>
+ prev.map((c) => {
+ const savedSnapState = savedSnapStatesRef.current.get(c.id);
+ if (savedSnapState) {
+ return { ...c, snapPosition: savedSnapState };
+ }
+ return c;
+ }),
+ );
+ }, []);
+
+ const value: ConsoleContextValue = {
+ consoles,
+ maxZIndex,
+ addConsole,
+ removeConsole,
+ updateConsole,
+ bringToFront,
+ snapConsole,
+ snapOut,
+ restoreSnapState,
+ exitToWindowMode,
+ };
+
+ return (
+ {children}
+ );
+}
+
+export { ConsoleContext };
diff --git a/src/pages/ConsolePage.tsx b/src/pages/ConsolePage.tsx
index 53ff069..28f62a8 100644
--- a/src/pages/ConsolePage.tsx
+++ b/src/pages/ConsolePage.tsx
@@ -1,70 +1,45 @@
-import { useState, useCallback, useRef } from "react";
+import { useState, useCallback, useRef, useContext, useEffect } from "react";
import { IconTerminal2, IconPlus } from "@tabler/icons-react";
import { SerialConsole } from "../components/SerialConsole";
import { DraggableWindow } from "../components/DraggableWindow";
+import { ConsoleContext } from "../contexts/ConsoleContext";
import type { WindowPosition } from "../components/DraggableWindow";
-interface ConsoleWindow {
- id: string;
- position: WindowPosition;
- zIndex: number;
- snapPosition?: "left" | "right" | "top" | "bottom" | null;
-}
-
type SnapPosition = "left" | "right" | "top" | "bottom" | null;
export function ConsolePage() {
- const [consoles, setConsoles] = useState([]);
- const [maxZIndex, setMaxZIndex] = useState(1000);
+ const consoleContext = useContext(ConsoleContext);
+ if (!consoleContext) {
+ throw new Error("ConsolePage must be used within ConsoleProvider");
+ }
+
+ const {
+ consoles,
+ addConsole,
+ removeConsole,
+ updateConsole,
+ bringToFront,
+ snapConsole: contextSnapConsole,
+ snapOut,
+ } = consoleContext;
+
const [draggedConsoleId, setDraggedConsoleId] = useState(null);
const [snapPreview, setSnapPreview] = useState(null);
- const nextIdRef = useRef(1);
const containerRef = useRef(null);
- const addConsole = useCallback(() => {
- const id = `console-${nextIdRef.current++}`;
- const newConsole: ConsoleWindow = {
- id,
- position: {
- x: 100 + ((consoles.length * 30) % 200),
- y: 100 + ((consoles.length * 30) % 200),
- width: 600,
- height: 400,
- },
- zIndex: maxZIndex + 1,
- snapPosition: null,
- };
- setConsoles((prev) => [...prev, newConsole]);
- setMaxZIndex((prev) => prev + 1);
- }, [consoles.length, maxZIndex]);
-
- const removeConsole = useCallback((id: string) => {
- setConsoles((prev) => prev.filter((c) => c.id !== id));
- }, []);
-
- const bringToFront = useCallback(
- (id: string) => {
- setConsoles((prev) =>
- prev.map((c) => (c.id === id ? { ...c, zIndex: maxZIndex + 1 } : c)),
- );
- setMaxZIndex((prev) => prev + 1);
+ const updatePosition = useCallback(
+ (id: string, position: WindowPosition) => {
+ updateConsole(id, { position });
},
- [maxZIndex],
+ [updateConsole],
);
- const updatePosition = useCallback((id: string, position: WindowPosition) => {
- setConsoles((prev) =>
- prev.map((c) => (c.id === id ? { ...c, position } : c)),
- );
- }, []);
-
- const snapConsole = useCallback((id: string, snapPos: SnapPosition) => {
- if (!snapPos || !containerRef.current) return;
-
- const rect = containerRef.current.getBoundingClientRect();
+ const snapConsoleToPosition = useCallback(
+ (id: string, snapPos: SnapPosition) => {
+ if (!snapPos || !containerRef.current) return;
- setConsoles((prev) => {
- const consolesToSnap = prev.filter(
+ const rect = containerRef.current.getBoundingClientRect();
+ const consolesToSnap = consoles.filter(
(c) => c.snapPosition === snapPos || c.id === id,
);
const othersSnapped = consolesToSnap.filter((c) => c.id !== id);
@@ -98,54 +73,45 @@ export function ConsolePage() {
};
}
- // Update positions of all consoles in this snap area
- const updated = prev.map((c) => {
- if (c.id === id) {
- return { ...c, position: newPosition, snapPosition: snapPos };
- }
- if (c.snapPosition === snapPos) {
- // Recalculate positions for existing snapped consoles
- const consoleIndex = othersSnapped.findIndex((oc) => oc.id === c.id);
- if (consoleIndex >= 0) {
- if (snapPos === "left" || snapPos === "right") {
- const width = rect.width / 2 / totalInSnap;
- const height = rect.height;
- return {
- ...c,
- position: {
- x:
- snapPos === "left"
- ? consoleIndex * width
- : rect.width / 2 + consoleIndex * width,
- y: 0,
- width,
- height,
- },
- };
- } else {
- const width = rect.width;
- const height = rect.height / 2 / totalInSnap;
- return {
- ...c,
- position: {
- x: 0,
- y:
- snapPos === "top"
- ? consoleIndex * height
- : rect.height / 2 + consoleIndex * height,
- width,
- height,
- },
- };
- }
- }
+ // Update this console
+ updateConsole(id, { position: newPosition, snapPosition: snapPos });
+ contextSnapConsole(id, snapPos);
+
+ // Recalculate positions for existing snapped consoles
+ othersSnapped.forEach((c, consoleIndex) => {
+ if (snapPos === "left" || snapPos === "right") {
+ const width = rect.width / 2 / totalInSnap;
+ const height = rect.height;
+ updateConsole(c.id, {
+ position: {
+ x:
+ snapPos === "left"
+ ? consoleIndex * width
+ : rect.width / 2 + consoleIndex * width,
+ y: 0,
+ width,
+ height,
+ },
+ });
+ } else {
+ const width = rect.width;
+ const height = rect.height / 2 / totalInSnap;
+ updateConsole(c.id, {
+ position: {
+ x: 0,
+ y:
+ snapPos === "top"
+ ? consoleIndex * height
+ : rect.height / 2 + consoleIndex * height,
+ width,
+ height,
+ },
+ });
}
- return c;
});
-
- return updated;
- });
- }, []);
+ },
+ [consoles, updateConsole, contextSnapConsole],
+ );
const handleDragEnd = useCallback(
(id: string, position: WindowPosition) => {
@@ -168,85 +134,65 @@ export function ConsolePage() {
}
if (snapPos) {
- snapConsole(id, snapPos);
+ snapConsoleToPosition(id, snapPos);
}
setDraggedConsoleId(null);
setSnapPreview(null);
},
- [snapConsole],
+ [snapConsoleToPosition],
);
- const snapOut = useCallback((id: string) => {
- setConsoles((prev) => {
- const console = prev.find((c) => c.id === id);
- if (!console || !console.snapPosition) return prev;
-
- const snapPos = console.snapPosition;
+ const handleSnapOut = useCallback(
+ (id: string) => {
+ snapOut(id);
- // Move to center with default size
- const newPosition: WindowPosition = {
- x: 100,
- y: 100,
- width: 600,
- height: 400,
- };
+ // Recalculate positions for remaining consoles in the same snap area
+ const console = consoles.find((c) => c.id === id);
+ if (!console || !console.snapPosition || !containerRef.current) return;
- const updated = prev.map((c) =>
- c.id === id ? { ...c, position: newPosition, snapPosition: null } : c,
+ const snapPos = console.snapPosition;
+ const rect = containerRef.current.getBoundingClientRect();
+ const remaining = consoles.filter(
+ (c) => c.snapPosition === snapPos && c.id !== id,
);
- // Recalculate positions for remaining consoles in the snap area
- if (containerRef.current) {
- const rect = containerRef.current.getBoundingClientRect();
- const remaining = updated.filter((c) => c.snapPosition === snapPos);
-
- if (remaining.length > 0) {
- return updated.map((c) => {
- if (c.snapPosition === snapPos) {
- const index = remaining.findIndex((rc) => rc.id === c.id);
- if (index >= 0) {
- if (snapPos === "left" || snapPos === "right") {
- const width = rect.width / 2 / remaining.length;
- const height = rect.height;
- return {
- ...c,
- position: {
- x:
- snapPos === "left"
- ? index * width
- : rect.width / 2 + index * width,
- y: 0,
- width,
- height,
- },
- };
- } else {
- const width = rect.width;
- const height = rect.height / 2 / remaining.length;
- return {
- ...c,
- position: {
- x: 0,
- y:
- snapPos === "top"
- ? index * height
- : rect.height / 2 + index * height,
- width,
- height,
- },
- };
- }
- }
- }
- return c;
- });
- }
+ if (remaining.length > 0) {
+ remaining.forEach((c, index) => {
+ if (snapPos === "left" || snapPos === "right") {
+ const width = rect.width / 2 / remaining.length;
+ const height = rect.height;
+ updateConsole(c.id, {
+ position: {
+ x:
+ snapPos === "left"
+ ? index * width
+ : rect.width / 2 + index * width,
+ y: 0,
+ width,
+ height,
+ },
+ });
+ } else {
+ const width = rect.width;
+ const height = rect.height / 2 / remaining.length;
+ updateConsole(c.id, {
+ position: {
+ x: 0,
+ y:
+ snapPos === "top"
+ ? index * height
+ : rect.height / 2 + index * height,
+ width,
+ height,
+ },
+ });
+ }
+ });
}
-
- return updated;
- });
- }, []);
+ },
+ [snapOut, consoles, updateConsole],
+ );
const handleDragStart = useCallback((id: string) => {
setDraggedConsoleId(id);
@@ -277,6 +223,69 @@ export function ConsolePage() {
[updatePosition],
);
+ // Recalculate snap positions on resize
+ useEffect(() => {
+ const handleResize = () => {
+ if (!containerRef.current) return;
+
+ const rect = containerRef.current.getBoundingClientRect();
+
+ // Group consoles by snap position
+ const snapGroups: Record = {
+ left: [],
+ right: [],
+ top: [],
+ bottom: [],
+ };
+
+ consoles.forEach((console) => {
+ if (console.snapPosition) {
+ snapGroups[console.snapPosition].push(console);
+ }
+ });
+
+ // Recalculate positions for each snap group
+ Object.entries(snapGroups).forEach(([snapPos, group]) => {
+ if (group.length === 0) return;
+
+ group.forEach((c, index) => {
+ if (snapPos === "left" || snapPos === "right") {
+ const width = rect.width / 2 / group.length;
+ const height = rect.height;
+ updateConsole(c.id, {
+ position: {
+ x:
+ snapPos === "left"
+ ? index * width
+ : rect.width / 2 + index * width,
+ y: 0,
+ width,
+ height,
+ },
+ });
+ } else {
+ const width = rect.width;
+ const height = rect.height / 2 / group.length;
+ updateConsole(c.id, {
+ position: {
+ x: 0,
+ y:
+ snapPos === "top"
+ ? index * height
+ : rect.height / 2 + index * height,
+ width,
+ height,
+ },
+ });
+ }
+ });
+ });
+ };
+
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, [consoles, updateConsole]);
+
const snappedConsoles = consoles.filter((c) => c.snapPosition !== null);
const floatingConsoles = consoles.filter((c) => c.snapPosition === null);
@@ -348,7 +357,7 @@ export function ConsolePage() {
consoleId={console.id}
onClose={() => removeConsole(console.id)}
isSnapped={true}
- onSnapOut={() => snapOut(console.id)}
+ onSnapOut={() => handleSnapOut(console.id)}
/>
))}
From b64796252a434ee3cf60a46d10ad668f7130b3c4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 4 Feb 2026 16:05:32 +0000
Subject: [PATCH 5/8] Add ZMK Studio connection fallback to serial console
- Modified DeviceConnection to support fallback to serial console
- Added onConnectWithFallback method with timeout
- Updated SplashScreen to use fallback connection
- Created reusableSerial transport wrapper (not yet in use)
- When ZMK connection fails, automatically opens serial console
Co-authored-by: cormoran <7994064+cormoran@users.noreply.github.com>
---
src/App.tsx | 1 +
src/components/DeviceConnection.tsx | 40 ++++++-
src/components/SplashScreen.tsx | 14 ++-
src/contexts/ConsoleContext.tsx | 22 ++++
src/lib/transport/reusableSerial.ts | 155 ++++++++++++++++++++++++++++
5 files changed, 229 insertions(+), 3 deletions(-)
create mode 100644 src/lib/transport/reusableSerial.ts
diff --git a/src/App.tsx b/src/App.tsx
index 0e28880..5c784ac 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -118,6 +118,7 @@ function AppContent() {
>
diff --git a/src/components/DeviceConnection.tsx b/src/components/DeviceConnection.tsx
index 6f5427c..cd4cac7 100644
--- a/src/components/DeviceConnection.tsx
+++ b/src/components/DeviceConnection.tsx
@@ -1,9 +1,10 @@
import type { ReactNode } from "react";
-import { createContext, useCallback } from "react";
+import { createContext, useCallback, useContext } from "react";
import { useZMKApp, ZMKAppContext } from "@cormoran/zmk-studio-react-hook";
import { connect as connectSerial } from "@zmkfirmware/zmk-studio-ts-client/transport/serial";
import { connect as connectBLE } from "@zmkfirmware/zmk-studio-ts-client/transport/gatt";
import { connect as connectDemo } from "../lib/transport/demo";
+import { ConsoleContext } from "../contexts/ConsoleContext";
export type ConnectionMethod = "serial" | "ble" | "demo";
@@ -15,6 +16,7 @@ interface ConnectionContextValue {
onDisconnect: () => void;
isLoading: boolean;
error: string | null;
+ onConnectWithFallback: (method: ConnectionMethod) => Promise
;
}
const ConnectionContext = createContext({
@@ -24,6 +26,7 @@ const ConnectionContext = createContext({
onDisconnect: () => {},
isLoading: false,
error: null,
+ onConnectWithFallback: async () => false,
});
interface DeviceConnectionProviderProps {
@@ -34,6 +37,7 @@ export function DeviceConnectionProvider({
children,
}: DeviceConnectionProviderProps) {
const zmkApp = useZMKApp();
+ const consoleContext = useContext(ConsoleContext);
const handleConnect = useCallback(
async (method: ConnectionMethod) => {
@@ -50,6 +54,39 @@ export function DeviceConnectionProvider({
[zmkApp],
);
+ // Connect with fallback to serial console if ZMK connection fails
+ const handleConnectWithFallback = useCallback(
+ async (method: ConnectionMethod): Promise => {
+ if (method !== "serial") {
+ // Non-serial connections don't have fallback
+ await handleConnect(method);
+ return true;
+ }
+
+ try {
+ // Try ZMK connection with timeout
+ const connectPromise = handleConnect(method);
+ const timeoutPromise = new Promise((_, reject) => {
+ setTimeout(() => reject(new Error("Connection timeout")), 5000);
+ });
+
+ await Promise.race([connectPromise, timeoutPromise]);
+ return true;
+ } catch {
+ // ZMK connection failed, fallback to serial console
+ console.log("ZMK connection failed, opening serial console...");
+
+ // Add a serial console (user will need to select port again)
+ if (consoleContext) {
+ consoleContext.addConsole();
+ }
+
+ return false;
+ }
+ },
+ [handleConnect, consoleContext],
+ );
+
const handleDisconnect = useCallback(() => {
zmkApp.disconnect();
}, [zmkApp]);
@@ -61,6 +98,7 @@ export function DeviceConnectionProvider({
onDisconnect: handleDisconnect,
isLoading: zmkApp.state.isLoading,
error: zmkApp.state.error,
+ onConnectWithFallback: handleConnectWithFallback,
};
return (
diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx
index 541186d..dbd9685 100644
--- a/src/components/SplashScreen.tsx
+++ b/src/components/SplashScreen.tsx
@@ -1,11 +1,12 @@
import { motion } from "framer-motion";
import { IconBluetooth, IconUsb, IconDeviceDesktop } from "@tabler/icons-react";
-import { useMemo } from "react";
+import { useMemo, useCallback } from "react";
import DyaLogo from "../assets/dya.svg?react";
import type { ConnectionMethod } from "./DeviceConnection";
interface SplashScreenProps {
onConnect: (method: ConnectionMethod) => void;
+ onConnectWithFallback?: (method: ConnectionMethod) => Promise;
isConnecting: boolean;
error: string | null;
}
@@ -21,6 +22,7 @@ function DisabledSlash({ color }: { color: string }) {
export function SplashScreen({
onConnect,
+ onConnectWithFallback,
isConnecting,
error,
}: SplashScreenProps) {
@@ -28,6 +30,14 @@ export function SplashScreen({
const isSerialAvailable = useMemo(() => "serial" in navigator, []);
const isBLEAvailable = useMemo(() => "bluetooth" in navigator, []);
+ const handleSerialConnect = useCallback(async () => {
+ if (onConnectWithFallback) {
+ await onConnectWithFallback("serial");
+ } else {
+ onConnect("serial");
+ }
+ }, [onConnect, onConnectWithFallback]);
+
return (