Files
kandev/apps/web/components/task/bottom-terminal-panel.tsx
T
José Almeida 9d275d3009 refactor: route user terminals by environment (#758)
* refactor: route user terminals by environment

* refactor: remove terminal compatibility shims

* fix: address terminal PR review feedback

* fix: wait for remote shell readiness

* fix: require agent mode for session terminals
2026-05-01 13:27:25 +01:00

109 lines
3.9 KiB
TypeScript

"use client";
import { useRef, useState, useCallback, useEffect } from "react";
import { IconMinus } from "@tabler/icons-react";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useEnvironmentId } from "@/hooks/use-environment-session-id";
import { PassthroughTerminal } from "./passthrough-terminal";
import { Button } from "@kandev/ui/button";
import { cn } from "@/lib/utils";
const MIN_HEIGHT = 150;
const MAX_HEIGHT_RATIO = 0.6;
const DEFAULT_HEIGHT = 300;
const STORAGE_KEY_HEIGHT = "bottom-terminal-height";
const STORAGE_KEY_OPEN = "bottom-terminal-open";
export function BottomTerminalPanel() {
const environmentId = useEnvironmentId();
const visible = useAppStore((s) => s.bottomTerminal.isOpen);
const pendingCommand = useAppStore((s) => s.bottomTerminal.pendingCommand);
const storeApi = useAppStoreApi();
const toggle = useCallback(() => storeApi.getState().toggleBottomTerminal(), [storeApi]);
const clearCommand = useCallback(
() => storeApi.getState().clearBottomTerminalCommand(),
[storeApi],
);
// Restore visibility state from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY_OPEN);
if (saved === "true" && !storeApi.getState().bottomTerminal.isOpen) {
toggle();
}
}, [storeApi, toggle]);
const panelRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(() => {
if (typeof window === "undefined") return DEFAULT_HEIGHT;
const saved = localStorage.getItem(STORAGE_KEY_HEIGHT);
return saved ? Math.max(MIN_HEIGHT, parseInt(saved, 10) || DEFAULT_HEIGHT) : DEFAULT_HEIGHT;
});
const isDragging = useRef(false);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
const startY = e.clientY;
const startHeight = height;
const onMouseMove = (moveEvent: MouseEvent) => {
if (!isDragging.current) return;
const delta = startY - moveEvent.clientY;
const maxHeight = window.innerHeight * MAX_HEIGHT_RATIO;
const newHeight = Math.min(maxHeight, Math.max(MIN_HEIGHT, startHeight + delta));
if (panelRef.current) panelRef.current.style.height = `${newHeight}px`;
};
const onMouseUp = () => {
isDragging.current = false;
if (panelRef.current) {
const finalHeight = panelRef.current.offsetHeight;
setHeight(finalHeight);
localStorage.setItem(STORAGE_KEY_HEIGHT, String(finalHeight));
}
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
},
[height],
);
if (!visible) return null;
return (
<div
ref={panelRef}
className={cn("relative flex flex-col border-t border-border bg-background", "shrink-0")}
style={{ height }}
>
{/* Resize handle */}
<div
className="absolute top-0 left-0 right-0 h-1 cursor-ns-resize hover:bg-primary/20 z-10"
onMouseDown={handleMouseDown}
/>
{/* Header */}
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-muted/30 shrink-0">
<span className="text-xs font-medium text-muted-foreground">Terminal</span>
<Button variant="ghost" size="icon" className="h-5 w-5 cursor-pointer" onClick={toggle}>
<IconMinus className="h-3 w-3" />
</Button>
</div>
{/* Terminal content */}
<div className="flex-1 min-h-0 overflow-hidden">
<PassthroughTerminal
mode="shell"
environmentId={environmentId}
terminalId="bottom-panel"
autoFocus
pendingCommand={pendingCommand}
onCommandSent={clearCommand}
/>
</div>
</div>
);
}