fix(web): sync fullscreen terminal viewport fix (#1140)

This commit is contained in:
yyovil 2026-04-12 04:22:27 +05:30
parent e5c371cb74
commit ddd96852ec
1 changed files with 521 additions and 268 deletions

View File

@ -72,326 +72,579 @@ export function buildTerminalThemes(variant: TerminalVariant): { dark: ITheme; l
cursorAccent: "#fafafa",
selectionBackground: accent.selLight,
selectionInactiveBackground: "rgba(128, 128, 128, 0.15)",
// ANSI colors — darkened for legibility on white
black: "#1f2937",
red: "#dc2626",
green: "#16a34a",
yellow: "#ca8a04",
blue: "#2563eb",
magenta: "#9333ea",
cyan: "#0891b2",
white: "#6b7280",
brightBlack: "#4b5563",
brightRed: "#ef4444",
brightGreen: "#22c55e",
brightYellow: "#eab308",
brightBlue: "#3b82f6",
brightMagenta: "#a855f7",
brightCyan: "#06b6d4",
brightWhite: "#9ca3af",
// ANSI colors — darkened for legibility on #fafafa terminal background
black: "#24292f",
red: "#b42318",
green: "#1f7a3d",
yellow: "#8a5a00",
blue: "#175cd3",
magenta: "#8e24aa",
cyan: "#0b7285",
white: "#4b5563",
brightBlack: "#374151",
brightRed: "#912018",
brightGreen: "#176639",
brightYellow: "#6f4a00",
brightBlue: "#1d4ed8",
brightMagenta: "#7b1fa2",
brightCyan: "#155e75",
brightWhite: "#374151",
};
return { dark, light };
}
/**
* Direct xterm.js terminal with native WebSocket connection.
* Implements Extended Device Attributes (XDA) handler to enable
* tmux clipboard support (OSC 52) without requiring iTerm2 attachment.
*
* Based on DeepWiki analysis:
* - tmux queries for XDA (CSI > q / XTVERSION) to detect terminal type
* - When tmux sees "XTerm(" in response, it enables TTYC_MS (clipboard)
* - xterm.js doesn't implement XDA by default, so we register custom handler
*/
export function DirectTerminal({
sessionId,
startFullscreen = false,
variant = "agent",
appearance = "theme",
height = "max(440px, calc(100vh - 440px))",
height = "max(440px, calc(100dvh - 440px))",
isOpenCodeSession = false,
reloadCommand = "/exit",
reloadCommand,
chromeless = false,
}: DirectTerminalProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { resolvedTheme } = useTheme();
const { subscribeTerminal, writeTerminal, openTerminal, closeTerminal, resizeTerminal, status: muxStatus } = useMux();
const terminalThemes = useMemo(() => buildTerminalThemes(variant), [variant]);
const { subscribeTerminal, writeTerminal, resizeTerminal: resizeTerminalMux, openTerminal, closeTerminal, status: muxStatus } = useMux();
const terminalRef = useRef<HTMLDivElement>(null);
const termRef = useRef<TerminalType | null>(null);
const fitAddonRef = useRef<FitAddonType | null>(null);
const cleanupRef = useRef<(() => void) | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const [connected, setConnected] = useState(false);
const terminalInstance = useRef<TerminalType | null>(null);
const fitAddon = useRef<FitAddonType | null>(null);
const muxStatusRef = useRef(muxStatus);
muxStatusRef.current = muxStatus;
const [fullscreen, setFullscreen] = useState(startFullscreen);
const [error, setError] = useState<string | null>(null);
const [reloading, setReloading] = useState(false);
const [reloadError, setReloadError] = useState<string | null>(null);
const terminalThemes = useMemo(() => buildTerminalThemes(variant), [variant]);
const isDark = appearance === "dark" || (appearance === "theme" && resolvedTheme !== "light");
const theme = isDark ? terminalThemes.dark : terminalThemes.light;
// Update URL when fullscreen changes
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
const updateFullscreenQuery = (next: boolean) => {
const params = new URLSearchParams(searchParams?.toString() ?? "");
if (next) {
params.set("fullscreen", sessionId);
} else if (params.get("fullscreen") === sessionId) {
if (fullscreen) {
params.set("fullscreen", "true");
} else {
params.delete("fullscreen");
}
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
};
useEffect(() => {
const requestedSession = searchParams?.get("fullscreen");
if (requestedSession === sessionId && !fullscreen) {
setFullscreen(true);
} else if (requestedSession !== sessionId && fullscreen && startFullscreen === false) {
setFullscreen(false);
}
}, [fullscreen, searchParams, sessionId, startFullscreen]);
const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname;
router.replace(newUrl, { scroll: false });
}, [fullscreen, pathname, router, searchParams]);
useEffect(() => {
let cancelled = false;
async function init() {
if (!terminalRef.current || termRef.current) return;
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
import("xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-web-links"),
]);
if (cancelled || !terminalRef.current) return;
const term = new Terminal({
theme,
convertEol: true,
cursorBlink: true,
allowTransparency: true,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: 13,
lineHeight: 1.25,
scrollback: 5000,
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
termRef.current = term;
fitAddonRef.current = fitAddon;
term.loadAddon(fitAddon);
term.loadAddon(webLinksAddon);
term.open(terminalRef.current);
fitAddon.fit();
// Keep xterm selection styling in sync on theme changes
term.options.theme = theme;
// Allow copy from selected text with Cmd/Ctrl+C without sending interrupt
term.attachCustomKeyEventHandler((e) => {
const isCopy = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "c";
if (isCopy && term.hasSelection()) {
navigator.clipboard?.writeText(term.getSelection());
return false;
}
return true;
});
const onDataDispose = term.onData((data) => {
writeTerminal(sessionId, data);
});
cleanupRef.current = subscribeTerminal(sessionId, (event) => {
if (event.type === "data") {
term.write(event.data);
setConnected(true);
} else if (event.type === "open") {
setConnected(true);
} else if (event.type === "error") {
setReloadError(event.message);
}
});
openTerminal(sessionId);
resizeObserverRef.current = new ResizeObserver(() => {
fitAddon.fit();
if (term.cols && term.rows) {
resizeTerminal(sessionId, term.cols, term.rows);
}
});
resizeObserverRef.current.observe(terminalRef.current);
const fontsReady = (document as Document & { fonts?: FontFaceSet }).fonts?.ready;
fontsReady?.then(() => {
fitAddon.fit();
if (term.cols && term.rows) {
resizeTerminal(sessionId, term.cols, term.rows);
}
});
return () => {
onDataDispose.dispose();
};
}
const disposePromise = init();
return () => {
cancelled = true;
disposePromise?.then((dispose) => dispose?.());
resizeObserverRef.current?.disconnect();
resizeObserverRef.current = null;
cleanupRef.current?.();
cleanupRef.current = null;
closeTerminal(sessionId);
termRef.current?.dispose();
termRef.current = null;
fitAddonRef.current = null;
setConnected(false);
};
}, [closeTerminal, openTerminal, resizeTerminal, sessionId, subscribeTerminal, theme, writeTerminal]);
useEffect(() => {
if (!termRef.current || !fitAddonRef.current) return;
termRef.current.options.theme = theme;
fitAddonRef.current.fit();
if (termRef.current.cols && termRef.current.rows) {
resizeTerminal(sessionId, termRef.current.cols, termRef.current.rows);
}
}, [resizeTerminal, sessionId, theme]);
useEffect(() => {
if (!fitAddonRef.current || !termRef.current) return;
const id = window.setTimeout(() => {
fitAddonRef.current?.fit();
if (termRef.current?.cols && termRef.current?.rows) {
resizeTerminal(sessionId, termRef.current.cols, termRef.current.rows);
}
}, 16);
return () => window.clearTimeout(id);
}, [fullscreen, resizeTerminal, sessionId]);
const handleToggleFullscreen = () => {
const next = !fullscreen;
setFullscreen(next);
updateFullscreenQuery(next);
};
const handleReload = async () => {
if (reloading || muxStatus !== "connected") return;
setReloading(true);
async function handleReload(): Promise<void> {
if (!isOpenCodeSession || reloading) return;
setReloadError(null);
setReloading(true);
try {
writeTerminal(sessionId, `${reloadCommand} `);
} catch (error) {
setReloadError(error instanceof Error ? error.message : "Failed to restart session");
let commandToSend = reloadCommand;
if (!commandToSend) {
const remapRes = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/remap`, {
method: "POST",
});
if (!remapRes.ok) {
throw new Error(`Failed to remap OpenCode session: ${remapRes.status}`);
}
const remapData = (await remapRes.json()) as { opencodeSessionId?: unknown };
if (
typeof remapData.opencodeSessionId !== "string" ||
remapData.opencodeSessionId.length === 0
) {
throw new Error("Missing OpenCode session id after remap");
}
commandToSend = `/exit\nopencode --session ${remapData.opencodeSessionId}\n`;
}
const sendRes = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: commandToSend }),
});
if (!sendRes.ok) {
throw new Error(`Failed to send reload command: ${sendRes.status}`);
}
} catch (err) {
setReloadError(err instanceof Error ? err.message : "Failed to reload OpenCode session");
} finally {
setReloading(false);
}
};
}
useEffect(() => {
if (!terminalRef.current) return;
// Dynamically import xterm.js to avoid SSR issues
let mounted = true;
let cleanup: (() => void) | null = null;
let inputDisposable: { dispose(): void } | null = null;
let unsubscribe: (() => void) | null = null;
Promise.all([
import("xterm").then((mod) => mod.Terminal),
import("@xterm/addon-fit").then((mod) => mod.FitAddon),
import("@xterm/addon-web-links").then((mod) => mod.WebLinksAddon),
document.fonts.ready,
])
.then(([Terminal, FitAddon, WebLinksAddon]) => {
if (!mounted || !terminalRef.current) return;
const isDark = appearance === "dark" || resolvedTheme !== "light";
const activeTheme = isDark ? terminalThemes.dark : terminalThemes.light;
// Initialize xterm.js Terminal
const terminal = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily:
'var(--font-jetbrains-mono), "JetBrains Mono", "SF Mono", Menlo, Monaco, "Courier New", monospace',
theme: activeTheme,
// Light mode needs an explicit contrast floor because agent UIs often emit
// dim/faint ANSI sequences that become unreadable on a near-white background.
minimumContrastRatio: isDark ? 1 : 7,
scrollback: 10000,
allowProposedApi: true,
fastScrollModifier: "alt",
fastScrollSensitivity: 3,
scrollSensitivity: 1,
});
// Add FitAddon for responsive sizing
const fit = new FitAddon();
terminal.loadAddon(fit);
fitAddon.current = fit;
// Add WebLinksAddon for clickable links
const webLinks = new WebLinksAddon();
terminal.loadAddon(webLinks);
// **CRITICAL FIX**: Register XDA (Extended Device Attributes) handler
// This makes tmux recognize our terminal and enable clipboard support
terminal.parser.registerCsiHandler(
{ prefix: ">", final: "q" }, // CSI > q is XTVERSION / XDA
() => {
// Respond with XTerm identification that tmux recognizes
// tmux looks for "XTerm(" in the response (see tmux tty-keys.c)
// Format: DCS > | XTerm(version) ST
// DCS = \x1bP, ST = \x1b\\
terminal.write("\x1bP>|XTerm(370)\x1b\\");
console.log("[DirectTerminal] Sent XDA response for clipboard support");
return true; // Handled
},
);
// Register OSC 52 handler for clipboard support
// tmux sends OSC 52 with base64-encoded text when copying
terminal.parser.registerOscHandler(52, (data) => {
const parts = data.split(";");
if (parts.length < 2) return false;
const b64 = parts[parts.length - 1];
try {
// Decode base64 → binary string → Uint8Array → UTF-8 text
// atob() alone only handles Latin-1; TextDecoder is needed for UTF-8
const binary = atob(b64);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
const text = new TextDecoder().decode(bytes);
navigator.clipboard?.writeText(text).catch(() => {});
} catch {
// Ignore decode errors
}
return true;
});
// Open terminal in DOM
terminal.open(terminalRef.current);
terminalInstance.current = terminal;
// Fit terminal to container
fit.fit();
// ── Preserve selection while terminal receives output ────────
// xterm.js clears the selection on every terminal.write(). We
// buffer incoming data while a selection is active so the
// highlight stays visible for Cmd+C. The buffer is flushed
// when the selection is cleared (click, keypress, etc.).
const writeBuffer: string[] = [];
let selectionActive = false;
let safetyTimer: ReturnType<typeof setTimeout> | null = null;
let bufferBytes = 0;
const MAX_BUFFER_BYTES = 1_048_576; // 1 MB
const flushWriteBuffer = () => {
if (safetyTimer) {
clearTimeout(safetyTimer);
safetyTimer = null;
}
if (writeBuffer.length > 0) {
terminal.write(writeBuffer.join(""));
writeBuffer.length = 0;
bufferBytes = 0;
}
};
const selectionDisposable = terminal.onSelectionChange(() => {
if (terminal.hasSelection()) {
selectionActive = true;
// Safety: flush after 5s to prevent unbounded buffering
if (!safetyTimer) {
safetyTimer = setTimeout(() => {
selectionActive = false;
flushWriteBuffer();
}, 5_000);
}
} else {
selectionActive = false;
flushWriteBuffer();
}
});
// Intercept Cmd+C (Mac) and Ctrl+Shift+C (Linux/Win) for copy.
// Paste (Cmd+V / Ctrl+Shift+V) is handled natively by xterm.js
// via its internal textarea — no custom handler needed.
terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {
if (e.type !== "keydown") return true;
// Cmd+C / Ctrl+Shift+C — copy selection
const isCopy =
(e.metaKey && !e.ctrlKey && !e.altKey && e.code === "KeyC") ||
(e.ctrlKey && e.shiftKey && e.code === "KeyC");
if (isCopy && terminal.hasSelection()) {
navigator.clipboard?.writeText(terminal.getSelection()).catch(() => {});
// Clear selection so the terminal resumes receiving output
terminal.clearSelection();
return false;
}
return true;
});
// Open terminal via mux
openTerminal(sessionId);
// Subscribe to terminal data via mux
unsubscribe = subscribeTerminal(sessionId, (data) => {
if (selectionActive) {
writeBuffer.push(data);
bufferBytes += data.length;
// Flush if buffer exceeds 1 MB to prevent OOM
if (bufferBytes > MAX_BUFFER_BYTES) {
selectionActive = false;
flushWriteBuffer();
}
} else {
terminal.write(data);
}
});
// Handle window resize
const handleResize = () => {
if (fit) {
fit.fit();
resizeTerminalMux(sessionId, terminal.cols, terminal.rows);
}
};
window.addEventListener("resize", handleResize);
// Terminal input → mux
inputDisposable = terminal.onData((data) => {
writeTerminal(sessionId, data);
});
// Send initial size
resizeTerminalMux(sessionId, terminal.cols, terminal.rows);
// Store cleanup function to be called from useEffect cleanup
cleanup = () => {
selectionDisposable.dispose();
if (safetyTimer) clearTimeout(safetyTimer);
window.removeEventListener("resize", handleResize);
inputDisposable?.dispose();
inputDisposable = null;
unsubscribe?.();
closeTerminal(sessionId);
terminal.dispose();
};
})
.catch((err) => {
console.error("[DirectTerminal] Failed to load xterm.js:", err);
setError("Failed to load terminal");
});
return () => {
mounted = false;
cleanup?.();
};
}, [
appearance,
sessionId,
variant,
resolvedTheme,
terminalThemes,
subscribeTerminal,
writeTerminal,
resizeTerminalMux,
openTerminal,
closeTerminal,
]);
// Re-send terminal dimensions on every reconnect so the server-side PTY
// matches the client's xterm.js size (new PTYs spawn at 80×24 default).
useEffect(() => {
if (muxStatus !== "connected") return;
const fit = fitAddon.current;
const terminal = terminalInstance.current;
if (!fit || !terminal) return;
fit.fit();
resizeTerminalMux(sessionId, terminal.cols, terminal.rows);
}, [muxStatus, sessionId, resizeTerminalMux]);
// Live theme switching without terminal recreation
useEffect(() => {
const terminal = terminalInstance.current;
if (!terminal) return;
const isDark = appearance === "dark" || resolvedTheme !== "light";
terminal.options.theme = isDark ? terminalThemes.dark : terminalThemes.light;
terminal.options.minimumContrastRatio = isDark ? 1 : 7;
}, [appearance, resolvedTheme, terminalThemes]);
// Re-fit terminal when fullscreen changes
useEffect(() => {
const fit = fitAddon.current;
const terminal = terminalInstance.current;
const container = terminalRef.current;
if (!fit || !terminal || muxStatusRef.current !== "connected" || !container) {
return;
}
let resizeAttempts = 0;
const maxAttempts = 60;
let cancelled = false;
let rafId = 0;
let lastHeight = -1;
const resizeTerminal = () => {
if (cancelled) return;
resizeAttempts++;
// Wait for the container height to stabilise (CSS transition finished)
const currentHeight = container.getBoundingClientRect().height;
const settled = lastHeight >= 0 && Math.abs(currentHeight - lastHeight) < 1;
lastHeight = currentHeight;
if (!settled && resizeAttempts < maxAttempts) {
// Container is still transitioning, try again next frame
rafId = requestAnimationFrame(resizeTerminal);
return;
}
// Container is at target size, now resize terminal
terminal.refresh(0, terminal.rows - 1);
fit.fit();
terminal.refresh(0, terminal.rows - 1);
// Send new size to server via mux
resizeTerminalMux(sessionId, terminal.cols, terminal.rows);
};
// Start resize polling
rafId = requestAnimationFrame(resizeTerminal);
// Also try on transitionend
const handleTransitionEnd = (e: TransitionEvent) => {
if (cancelled) return;
if (e.target === container.parentElement) {
resizeAttempts = 0;
lastHeight = -1;
setTimeout(() => {
if (!cancelled) rafId = requestAnimationFrame(resizeTerminal);
}, 50);
}
};
const parent = container.parentElement;
parent?.addEventListener("transitionend", handleTransitionEnd);
// Backup timers in case RAF polling doesn't work
const timer1 = setTimeout(() => {
if (cancelled) return;
resizeAttempts = 0;
lastHeight = -1;
resizeTerminal();
}, 300);
const timer2 = setTimeout(() => {
if (cancelled) return;
resizeAttempts = 0;
lastHeight = -1;
resizeTerminal();
}, 600);
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
parent?.removeEventListener("transitionend", handleTransitionEnd);
clearTimeout(timer1);
clearTimeout(timer2);
};
}, [fullscreen, sessionId, resizeTerminalMux]);
const accentColor = "var(--color-accent)";
// Local errors (e.g. xterm.js load failure) take priority over mux connection state
const displayStatus = error ? "error" : muxStatus;
const statusDotClass =
displayStatus === "connected"
? "bg-[var(--color-status-ready)]"
: displayStatus === "error" || displayStatus === "disconnected"
? "bg-[var(--color-status-error)]"
: "bg-[var(--color-status-attention)] animate-[pulse_1.5s_ease-in-out_infinite]";
const statusText =
displayStatus === "connected"
? "Connected"
: displayStatus === "error"
? (error ?? "Error")
: displayStatus === "disconnected"
? "Disconnected"
: "Connecting…";
const statusTextColor =
displayStatus === "connected"
? "text-[var(--color-status-ready)]"
: displayStatus === "error" || displayStatus === "disconnected"
? "text-[var(--color-status-error)]"
: "text-[var(--color-text-tertiary)]";
const isDarkChrome = appearance === "dark" || resolvedTheme !== "light";
const fullscreenButton = (
<button
onClick={handleToggleFullscreen}
className="inline-flex items-center gap-1 rounded-[6px] border border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)] px-2 py-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)]"
onClick={() => setFullscreen(!fullscreen)}
className={cn(
"flex items-center gap-1 px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)]",
!isOpenCodeSession && !chromeless && "ml-auto",
)}
aria-label={fullscreen ? "exit fullscreen" : "fullscreen"}
title={fullscreen ? "Exit fullscreen" : "Fullscreen"}
>
{fullscreen ? (
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M8 3H3v5" />
<path d="M16 3h5v5" />
<path d="M8 21H3v-5" />
<path d="M16 21h5v-5" />
</svg>
<>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3" />
</svg>
exit fullscreen
</>
) : (
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M8 3H3v5" />
<path d="M16 3h5v5" />
<path d="M8 21H3v-5" />
<path d="M16 21h5v-5" />
</svg>
<>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3" />
</svg>
fullscreen
</>
)}
{fullscreen ? "Exit" : "Fullscreen"}
</button>
);
return (
<div
className={cn(
"overflow-hidden border border-[var(--color-border)] bg-[var(--color-bg-panel)] shadow-[var(--shadow-sm)]",
fullscreen
? "fixed inset-0 z-50 rounded-none border-0"
: "relative rounded-[12px]",
"overflow-hidden border border-[var(--color-border-default)]",
fullscreen ? "fixed inset-0 z-50 rounded-none border-0" : "relative",
isDarkChrome ? "bg-[#0a0a0f]" : "bg-[#fafafa]",
chromeless && "border-0",
)}
style={{
...(fullscreen ? { width: "100vw", height: "100dvh" } : {}),
}}
>
{!chromeless ? (
<div className="flex h-[37px] items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-bg-elevated)] px-3">
<div className="flex min-w-0 items-center gap-2">
<span className="text-[12px] font-semibold uppercase tracking-[0.08em] text-[var(--color-accent)]">
XDA
</span>
<span className="truncate text-[12px] text-[var(--color-text-secondary)]">
{sessionId}
</span>
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
connected
? "bg-[color-mix(in_srgb,var(--color-accent)_18%,transparent)] text-[var(--color-accent)]"
: "bg-[var(--color-bg-subtle)] text-[var(--color-text-tertiary)]",
)}
<div className="flex items-center gap-2 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)] px-3 py-2">
<div className={cn("h-2 w-2 shrink-0 rounded-full", statusDotClass)} />
<span className="font-[var(--font-mono)] text-[11px]" style={{ color: accentColor }}>
{sessionId}
</span>
<span
className={cn("text-[10px] font-medium uppercase tracking-[0.06em]", statusTextColor)}
>
{statusText}
</span>
<span
className="px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.06em]"
style={{
color: accentColor,
background: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
}}
>
XDA
</span>
{isOpenCodeSession ? (
<button
onClick={handleReload}
disabled={reloading || muxStatus !== "connected"}
title="Restart OpenCode session (/exit then resume mapped session)"
aria-label="Restart OpenCode session"
className="ml-auto flex items-center gap-1 px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-70"
>
{connected ? "Connected" : "Connecting"}
{reloading ? (
<>
<svg
className="h-3 w-3 animate-spin"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M12 3a9 9 0 109 9" />
</svg>
restarting
</>
) : (
<>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M21 12a9 9 0 11-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
restart
</>
)}
</button>
) : null}
{reloadError ? (
<span
className="max-w-[40ch] truncate text-[10px] font-medium text-[var(--color-status-error)]"
title={reloadError}
>
{reloadError}
</span>
</div>
<div className="flex items-center gap-2">
{isOpenCodeSession ? (
<button
onClick={handleReload}
disabled={reloading || muxStatus !== "connected"}
title="Restart OpenCode session (/exit then resume mapped session)"
aria-label="Restart OpenCode session"
className="inline-flex items-center gap-1 rounded-[6px] border border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)] px-2 py-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-70"
>
{reloading ? (
<>
<svg
className="h-3 w-3 animate-spin"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M12 3a9 9 0 109 9" />
</svg>
restarting
</>
) : (
<>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M21 12a9 9 0 11-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
restart
</>
)}
</button>
) : null}
{reloadError ? (
<span
className="max-w-[220px] truncate text-[10px] font-medium text-[var(--color-status-error)]"
title={reloadError}
>
{reloadError}
</span>
) : null}
{fullscreenButton}
</div>
) : null}
{fullscreenButton}
</div>
) : null}
{chromeless ? (