Files
headquarter/apps/web/src/hooks/use-terminal-page.ts
T
Developer 82ff8b8801 fix: route terminal callbacks and status per session
The recent redraw fix keeps all xterm instances mounted (display:none)
when switching sessions. However, sendData/focus/font-size refs and the
header connection status were still stored globally, so the last-mounted
hidden session could own keyboard input, font-size buttons, and the
status dot for the active session.

- Pass sessionId to onTerminalReady from TerminalComponent.
- Store terminal callbacks and status keyed by sessionId in use-terminal-page.
- Use activeSessionId to route special-key input, font-size changes, and header status.
- Clean up per-session refs and status when sessions are closed.
- Update MobileTerminalWrapper signature for the new callback shape.

Quality gates: npm run typecheck, npm run lint, npm test (87 passed)
2026-06-15 13:53:09 +00:00

369 lines
11 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import type { TerminalRef } from "../components/features/terminal/terminal";
import type { TerminalSessionInfo } from "../components/features/terminal/terminal-session-tabs";
import { useMobileViewport } from "./use-mobile-viewport";
import { useAutoHide } from "./use-auto-hide";
import { useVirtualKeyboard } from "./use-virtual-keyboard";
import { useTerminalSessions } from "./use-terminal-sessions";
import type { TerminalSession } from "../api/terminal";
import type { ModifierKey } from "./use-special-keys";
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
sessions.map((s) => ({
id: s.id,
name: s.name,
status: s.status as TerminalSessionInfo["status"],
}));
type TerminalStatus =
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting";
export const useTerminalPage = () => {
const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const [terminalStatuses, setTerminalStatuses] = useState<
Record<string, TerminalStatus>
>({});
const changeFontSizeRefs = useRef<
Record<string, ((delta: number) => void) | null>
>({});
const sendDataRefs = useRef<Record<string, ((data: string) => void) | null>>(
{},
);
const focusInputRefs = useRef<Record<string, (() => void) | null>>({});
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
null,
);
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const [instanceInfo, setInstanceInfo] = useState<{
display_name: string;
workspace_name?: string | null;
tool_type_name: string;
} | null>(null);
const {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
} = useTerminalSessions(instanceId ?? "");
// Fetch instance details for tab title
useEffect(() => {
if (!instanceId) return;
const load = async () => {
try {
const { getUserSessions } = await import("../api/sessions");
const allSessions = await getUserSessions();
const match = allSessions.find((s) => s.id === instanceId);
if (match) {
setInstanceInfo({
display_name: match.display_name,
workspace_name: match.workspace_name,
tool_type_name: match.tool_type_name,
});
}
} catch {
// ignore
}
};
void load();
}, [instanceId]);
// Auto-create default session
useEffect(() => {
if (!loading && sessions.length === 0 && !error && instanceId) {
void createSession("Session 1");
}
}, [loading, sessions.length, error, instanceId, createSession]);
// Update document title based on active terminal session
useEffect(() => {
if (!instanceId) {
document.title = "Terminal";
return;
}
const active = sessions.find((s) => s.id === activeSessionId);
const baseName = instanceInfo
? `${instanceInfo.workspace_name ?? instanceInfo.display_name} · ${instanceInfo.tool_type_name}`
: `Instance ${instanceId.slice(0, 8)}`;
if (sessions.length <= 1) {
document.title = baseName;
} else {
const sessionName = active?.name ?? "Session";
document.title = `${baseName} ${sessionName}`;
}
return () => {
document.title = "Headquarter";
};
}, [instanceId, activeSessionId, sessions, instanceInfo]);
// Sync refs with sessions
useEffect(() => {
for (const session of sessions) {
if (!terminalRefs.current[session.id]) {
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
}
}
const currentIds = new Set(sessions.map((s) => s.id));
for (const id of Object.keys(terminalRefs.current)) {
if (!currentIds.has(id)) {
delete terminalRefs.current[id];
delete sendDataRefs.current[id];
delete focusInputRefs.current[id];
delete changeFontSizeRefs.current[id];
}
}
setTerminalStatuses((prev) => {
const next: Record<string, TerminalStatus> = {};
for (const id of currentIds) {
if (prev[id]) {
next[id] = prev[id];
}
}
return next;
});
}, [sessions]);
// Fit and focus active terminal
useEffect(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
const ref = terminalRefs.current[activeSessionId];
let raf1 = 0;
let raf2 = 0;
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
ref.current?.fit();
ref.current?.focus();
});
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}
}, [activeSessionId]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
if (!isAltShift) return;
switch (e.key.toLowerCase()) {
case "n":
e.preventDefault();
if (sessions.length < 5) {
void createSession(`Session ${sessions.length + 1}`);
}
break;
case "w":
e.preventDefault();
if (
activeSessionId &&
window.confirm("Close this terminal session?")
) {
void closeSession(activeSessionId);
}
break;
case "arrowleft":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
}
break;
case "arrowright":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx < sessions.length - 1)
setActiveSessionId(sessions[idx + 1].id);
}
break;
case "r":
e.preventDefault();
if (activeSessionId) void resetSession(activeSessionId);
break;
case "f":
e.preventDefault();
setIsFullscreen((prev) => !prev);
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
sessions,
activeSessionId,
createSession,
closeSession,
resetSession,
setActiveSessionId,
]);
// Keep screen awake
useEffect(() => {
let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => {
try {
if ("wakeLock" in navigator) {
wakeLock = await navigator.wakeLock.request("screen");
}
} catch {
// ignore
}
};
void requestWakeLock();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") void requestWakeLock();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
wakeLock?.release().catch(() => {});
};
}, []);
// Lock page scroll on mobile
useEffect(() => {
if (!isMobile) return;
document.documentElement.classList.add("terminal-page-open");
document.body.classList.add("terminal-page-open");
return () => {
document.documentElement.classList.remove("terminal-page-open");
document.body.classList.remove("terminal-page-open");
};
}, [isMobile]);
const handleFullscreenClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
if (!isFullscreen) return;
const target = e.target as Node;
const current = e.currentTarget as HTMLElement;
const content = current.querySelector(".terminal-page-content");
const header = current.querySelector(".terminal-fullscreen-header");
if (content?.contains(target) || header?.contains(target)) return;
setIsFullscreen(false);
},
[isFullscreen],
);
const handleSelect = useCallback(
(sessionId: string) => setActiveSessionId(sessionId),
[setActiveSessionId],
);
const handleClose = useCallback(
async (sessionId: string) => closeSession(sessionId),
[closeSession],
);
const handleCreate = useCallback(() => {
void createSession(`Session ${sessions.length + 1}`);
}, [createSession, sessions.length]);
const handleRename = useCallback(
(sessionId: string, newName: string) => {
void renameSession(sessionId, newName);
},
[renameSession],
);
const handleTerminalReady = useCallback(
(
sessionId: string | undefined,
sendData: (data: string) => void,
status: TerminalStatus,
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => {
const key = sessionId ?? "default";
setTerminalStatuses((prev) => ({
...prev,
[key]: status,
}));
sendDataRefs.current[key] = sendData;
focusInputRefs.current[key] = focusInput;
changeFontSizeRefs.current[key] = changeFontSize;
},
[],
);
const handleFontSizeChange = useCallback(
(delta: number) => {
const key = activeSessionId ?? "default";
changeFontSizeRefs.current[key]?.(delta);
},
[activeSessionId],
);
const handleSendKey = useCallback(
(data: string) => {
const key = activeSessionId ?? "default";
sendDataRefs.current[key]?.(data);
},
[activeSessionId],
);
const handleReset = useCallback(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
terminalRefs.current[activeSessionId].current?.reset();
}
}, [activeSessionId]);
return {
instanceId,
navigate,
isMobile,
isFullscreen,
setIsFullscreen,
terminalRefs,
headerAutoHide,
terminalStatuses,
showResetConfirm,
setShowResetConfirm,
showSpecialKeysPanel,
setShowSpecialKeysPanel,
activeModifier,
setActiveModifier,
isKeyboardOpen,
keyboardHeight,
sessions,
activeSessionId,
setActiveSessionId,
loading,
error,
handleFullscreenClick,
handleSelect,
handleClose,
handleCreate,
handleRename,
handleTerminalReady,
handleFontSizeChange,
handleSendKey,
handleReset,
sessionInfos: SESSIONS_TO_INFO(sessions),
};
};