- {sidebarOpen && (
- <>
-
setSidebarOpen(false)} />
-
- >
- )}
+ setSidebarOpen(false)} />
{!sidebarOpen && (
+ );
+ }
const groupLabel = primaryTag === "all" ? "All Sessions" : primaryTag;
@@ -139,7 +156,7 @@ export function MainContent({ client }: MainContentProps) {
{header}
- {displaySession && }
+ {displaySession && }
);
diff --git a/web/src/components/MobileDrawer.tsx b/web/src/components/MobileDrawer.tsx
new file mode 100644
index 0000000..674724b
--- /dev/null
+++ b/web/src/components/MobileDrawer.tsx
@@ -0,0 +1,190 @@
+import { useState, useCallback, useEffect, useRef } from "preact/hooks";
+import type { WshClient } from "../api/ws";
+import { groups, selectedGroups } from "../state/groups";
+import { focusedSession, connectionState, sessionInfoMap } from "../state/sessions";
+import { ThemePicker } from "./ThemePicker";
+
+interface MobileDrawerProps {
+ client: WshClient;
+ open: boolean;
+ onClose: () => void;
+}
+
+export function MobileDrawer({ client, open, onClose }: MobileDrawerProps) {
+ const [autoRotate, setAutoRotate] = useState(true);
+ const [menuSession, setMenuSession] = useState
(null);
+ const [visible, setVisible] = useState(false);
+ const [closing, setClosing] = useState(false);
+ const drawerRef = useRef(null);
+
+ useEffect(() => {
+ if (open) {
+ setVisible(true);
+ setClosing(false);
+ } else if (visible) {
+ setClosing(true);
+ const el = drawerRef.current;
+ if (el) {
+ const onEnd = () => { setVisible(false); setClosing(false); };
+ el.addEventListener("animationend", onEnd, { once: true });
+ // Fallback in case animationend doesn't fire
+ const timer = setTimeout(onEnd, 250);
+ return () => { clearTimeout(timer); el.removeEventListener("animationend", onEnd); };
+ } else {
+ setVisible(false);
+ setClosing(false);
+ }
+ }
+ }, [open]);
+
+ if (!visible) return null;
+
+ const allGroups = groups.value;
+ const focused = focusedSession.value;
+ const connState = connectionState.value;
+ const infoMap = sessionInfoMap.value;
+
+ const handleSessionTap = (session: string, groupTag: string) => {
+ focusedSession.value = session;
+ selectedGroups.value = [groupTag];
+ onClose();
+ };
+
+ const handleNewSession = () => {
+ client.createSession().then((info) => {
+ focusedSession.value = info.name;
+ selectedGroups.value = ["all"];
+ }).catch((e) => console.error("Failed to create session:", e));
+ onClose();
+ };
+
+ const handleRename = (session: string) => {
+ setMenuSession(null);
+ const newName = prompt("Rename session:", session);
+ if (newName && newName !== session) {
+ client.renameSession(session, newName)
+ .catch((e: unknown) => console.error("Failed to rename session:", e));
+ }
+ };
+
+ const handleTag = (session: string) => {
+ setMenuSession(null);
+ const info = infoMap.get(session);
+ const currentTags = info?.tags?.join(", ") || "";
+ const input = prompt("Tags (comma-separated):", currentTags);
+ if (input === null) return;
+ const newTags = input.split(",").map(t => t.trim()).filter(Boolean);
+ const oldTags = info?.tags || [];
+ const toAdd = newTags.filter(t => !oldTags.includes(t));
+ const toRemove = oldTags.filter(t => !newTags.includes(t));
+ if (toAdd.length > 0 || toRemove.length > 0) {
+ client.updateTags(session, toAdd, toRemove)
+ .catch((e: unknown) => console.error("Failed to update tags:", e));
+ }
+ };
+
+ const handleKill = (session: string) => {
+ setMenuSession(null);
+ client.killSession(session).then(() => {
+ if (focusedSession.value === session) {
+ const currentAll = groups.value.find(g => g.tag === "all")?.sessions || [];
+ const remaining = currentAll.filter(s => s !== session);
+ focusedSession.value = remaining[0] || null;
+ }
+ }).catch((e) => console.error("Failed to kill session:", e));
+ };
+
+ const toggleAutoRotate = useCallback(() => {
+ const next = !autoRotate;
+ setAutoRotate(next);
+ try {
+ const orient = screen.orientation as any;
+ if (next) {
+ orient.unlock?.();
+ } else {
+ orient.lock?.("portrait")?.catch?.(() => {});
+ }
+ } catch {
+ // orientation lock not supported
+ }
+ }, [autoRotate]);
+
+ const displayGroups = allGroups.filter(g => g.tag !== "all" && g.sessions.length > 0);
+ const allSessions = allGroups.find(g => g.tag === "all")?.sessions || [];
+ const showFlat = displayGroups.length === 0;
+
+ const renderSession = (session: string, groupTag: string) => (
+
+ handleSessionTap(session, groupTag)}
+ >
+ {session}
+
+
+ {menuSession === session && (
+
+ )}
+
+ );
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/web/src/components/Terminal.tsx b/web/src/components/Terminal.tsx
index 0a978f9..d32f843 100644
--- a/web/src/components/Terminal.tsx
+++ b/web/src/components/Terminal.tsx
@@ -184,6 +184,14 @@ export function Terminal({ session, client, captureInput }: TerminalProps) {
}
}, [captureInput]);
+ // On mobile, prevent mousedown on the terminal from blurring the input bar.
+ // This lets users scroll the terminal without closing the keyboard.
+ const handleMouseDown = useCallback((e: MouseEvent) => {
+ if (!captureInput) {
+ e.preventDefault();
+ }
+ }, [captureInput]);
+
// Measure character cell size and compute cols/rows for a given container size
const computeGridSize = useCallback(() => {
const measure = measureRef.current;
@@ -372,6 +380,7 @@ export function Terminal({ session, client, captureInput }: TerminalProps) {
class="terminal-wrapper"
style={{ fontSize: `${fontSize}px` }}
onClick={handleWrapperClick}
+ onMouseDown={handleMouseDown}
>
{captureInput && (