From 5f545cb8b649a849f0032f3efcfbc8c19b1e50e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:50:42 +0000 Subject: [PATCH 1/8] Initial plan From 87417fd4b0af33d5e10902d00b7162b4a43f8036 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:53:00 +0000 Subject: [PATCH 2/8] Initial plan for serial console feature implementation Co-authored-by: cormoran <7994064+cormoran@users.noreply.github.com> --- package-lock.json | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index bdd71ea..02dbca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,6 +132,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -965,6 +966,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -988,6 +990,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3776,6 +3779,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -4278,8 +4282,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4523,6 +4526,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4533,6 +4537,7 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4543,6 +4548,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4657,6 +4663,7 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -4934,6 +4941,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5329,6 +5337,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6041,8 +6050,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dot-case": { "version": "3.0.4", @@ -6194,6 +6202,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7223,6 +7232,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9113,6 +9123,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -9824,7 +9835,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10421,6 +10431,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10469,7 +10480,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10485,7 +10495,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -10563,6 +10572,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10572,6 +10582,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10591,6 +10602,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -10736,7 +10748,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -11500,6 +11513,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11622,6 +11636,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11818,6 +11833,7 @@ "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/runtime": "0.97.0", "fdir": "^6.5.0", @@ -12176,6 +12192,7 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From aa0b486685dfd6b7d25d1b9dad44bdb398c4f55c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:57:46 +0000 Subject: [PATCH 3/8] Add serial console components and Console tab - Created useSerialPort hook for Web Serial API integration - Created SerialConsole component with filtering and text replacement - Created DraggableWindow component for window management - Added ConsolePage with snap functionality - Added Console tab to main navigation Co-authored-by: cormoran <7994064+cormoran@users.noreply.github.com> --- src/App.tsx | 8 + src/components/DraggableWindow.tsx | 225 ++++++++++++++++ src/components/SerialConsole.tsx | 306 ++++++++++++++++++++++ src/hooks/useSerialPort.ts | 175 +++++++++++++ src/pages/ConsolePage.tsx | 403 +++++++++++++++++++++++++++++ 5 files changed, 1117 insertions(+) create mode 100644 src/components/DraggableWindow.tsx create mode 100644 src/components/SerialConsole.tsx create mode 100644 src/hooks/useSerialPort.ts create mode 100644 src/pages/ConsolePage.tsx diff --git a/src/App.tsx b/src/App.tsx index 8ead759..a4abb7f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { IconKeyboard, IconPointer, IconSettings, + IconTerminal2, } from "@tabler/icons-react"; import { SplashScreen } from "./components/SplashScreen"; @@ -24,6 +25,7 @@ import { HealthCheckPage } from "./pages/HealthCheckPage"; import { KeymapPage } from "./pages/KeymapPage"; import { TrackballPage } from "./pages/TrackballPage"; import { SettingsPage } from "./pages/SettingsPage"; +import { ConsolePage } from "./pages/ConsolePage"; const tabs: TabItem[] = [ { @@ -62,6 +64,12 @@ const tabs: TabItem[] = [ icon: , content: , }, + { + id: "console", + label: "Console", + icon: , + content: , + }, ]; function App() { diff --git a/src/components/DraggableWindow.tsx b/src/components/DraggableWindow.tsx new file mode 100644 index 0000000..b7c0cb7 --- /dev/null +++ b/src/components/DraggableWindow.tsx @@ -0,0 +1,225 @@ +import { useState, useRef, useCallback, useEffect } from "react"; +import type { ReactNode } from "react"; + +export interface WindowPosition { + x: number; + y: number; + width: number; + height: number; +} + +interface DraggableWindowProps { + children: ReactNode; + initialPosition?: Partial; + onPositionChange?: (position: WindowPosition) => void; + onDragStart?: () => void; + onDragEnd?: (position: WindowPosition) => void; + zIndex?: number; + onFocus?: () => void; +} + +export function DraggableWindow({ + children, + initialPosition = {}, + onPositionChange, + onDragStart, + onDragEnd, + zIndex = 1000, + onFocus, +}: DraggableWindowProps) { + const [position, setPosition] = useState({ + x: initialPosition.x ?? 100, + y: initialPosition.y ?? 100, + width: initialPosition.width ?? 600, + height: initialPosition.height ?? 400, + }); + + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [resizeDirection, setResizeDirection] = useState(""); + + const dragStartPos = useRef({ x: 0, y: 0 }); + const windowRef = useRef(null); + const resizeStartPos = useRef({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest(".window-content")) { + return; // Don't drag when interacting with content + } + + e.preventDefault(); + setIsDragging(true); + dragStartPos.current = { + x: e.clientX - position.x, + y: e.clientY - position.y, + }; + onDragStart?.(); + onFocus?.(); + }, + [position.x, position.y, onDragStart, onFocus], + ); + + const handleResizeMouseDown = useCallback( + (e: React.MouseEvent, direction: string) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + setResizeDirection(direction); + resizeStartPos.current = { + x: e.clientX, + y: e.clientY, + width: position.width, + height: position.height, + }; + onFocus?.(); + }, + [position.width, position.height, onFocus], + ); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDragging) { + const newPosition = { + ...position, + x: e.clientX - dragStartPos.current.x, + y: e.clientY - dragStartPos.current.y, + }; + setPosition(newPosition); + onPositionChange?.(newPosition); + } else if (isResizing) { + const deltaX = e.clientX - resizeStartPos.current.x; + const deltaY = e.clientY - resizeStartPos.current.y; + + let newWidth = position.width; + let newHeight = position.height; + let newX = position.x; + let newY = position.y; + + if (resizeDirection.includes("e")) { + newWidth = Math.max(300, resizeStartPos.current.width + deltaX); + } + if (resizeDirection.includes("s")) { + newHeight = Math.max(200, resizeStartPos.current.height + deltaY); + } + if (resizeDirection.includes("w")) { + const widthDelta = resizeStartPos.current.width - deltaX; + newWidth = Math.max(300, widthDelta); + newX = position.x + (resizeStartPos.current.width - newWidth); + } + if (resizeDirection.includes("n")) { + const heightDelta = resizeStartPos.current.height - deltaY; + newHeight = Math.max(200, heightDelta); + newY = position.y + (resizeStartPos.current.height - newHeight); + } + + const newPosition = { + x: newX, + y: newY, + width: newWidth, + height: newHeight, + }; + setPosition(newPosition); + onPositionChange?.(newPosition); + } + }; + + const handleMouseUp = () => { + if (isDragging) { + setIsDragging(false); + onDragEnd?.(position); + } + if (isResizing) { + setIsResizing(false); + setResizeDirection(""); + } + }; + + if (isDragging || isResizing) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [ + isDragging, + isResizing, + position, + resizeDirection, + onPositionChange, + onDragEnd, + ]); + + return ( +
+ {/* Window Header (draggable) */} +
+
{children}
+
+ + {/* Resize handles */} + {!isDragging && ( + <> + {/* Corner handles */} +
handleResizeMouseDown(e, "nw")} + /> +
handleResizeMouseDown(e, "ne")} + /> +
handleResizeMouseDown(e, "sw")} + /> +
handleResizeMouseDown(e, "se")} + /> + + {/* Edge handles */} +
handleResizeMouseDown(e, "n")} + /> +
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 && ( +
+
+ + setFilterRegex(e.target.value)} + placeholder="Regex pattern..." + className="flex-1 input-field text-xs" + /> +
+
+ + setReplacePattern(e.target.value)} + placeholder="Pattern..." + className="flex-1 input-field text-xs" + /> + + setReplaceWith(e.target.value)} + placeholder="Replacement..." + className="flex-1 input-field text-xs" + /> +
+
+ )} + + {/* Configuration Panel */} + {showConfig && ( +
+
+
+
+ +
+

+ Serial Port Configuration +

+
+ +
+
+ + +
+ +
+ + {onClose && ( + + )} +
+ + {error && ( +
+

{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 (