diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index bb62d8edbc0..c93febf529d 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1911,6 +1911,7 @@ describe("ChatView timeline estimator parity (full app)", () => { [THREAD_KEY]: { terminalOpen: true, terminalHeight: 280, + terminalWidth: 900, terminalIds: ["default"], runningTerminalIds: [], activeTerminalId: "default", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..af82c37a6e1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -12,6 +12,7 @@ import { type ServerProvider, type ResolvedKeybindingsConfig, type ScopedThreadRef, + type TerminalLayout, type ThreadId, type TurnId, type KeybindingCommand, @@ -35,7 +36,7 @@ import { import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { Debouncer } from "@tanstack/react-pacer"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; @@ -88,6 +89,7 @@ import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, + DEFAULT_THREAD_TERMINAL_WIDTH, MAX_TERMINALS_PER_GROUP, type ChatMessage, type SessionPhase, @@ -104,7 +106,7 @@ import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon, XIcon } from "lucide-react"; import { cn, randomUUID } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -428,6 +430,17 @@ function useLocalDispatchState(input: { }; } +const MIN_FLOATING_TERMINAL_WIDTH = 400; +const MAX_FLOATING_TERMINAL_WIDTH_RATIO = 0.97; + +function clampFloatingTerminalWidth(w: number): number { + if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_WIDTH; + return Math.min( + Math.max(Math.round(w), MIN_FLOATING_TERMINAL_WIDTH), + Math.floor(window.innerWidth * MAX_FLOATING_TERMINAL_WIDTH_RATIO), + ); +} + interface PersistentThreadTerminalDrawerProps { threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; @@ -438,6 +451,7 @@ interface PersistentThreadTerminalDrawerProps { newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; + terminalLayout: TerminalLayout; onAddTerminalContext: (selection: TerminalContextSelection) => void; } @@ -451,6 +465,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra newShortcutLabel, closeShortcutLabel, keybindings, + terminalLayout, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); @@ -465,11 +480,27 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra selectThreadTerminalState(state.terminalStateByThreadKey, threadRef), ); const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); + const storeSetTerminalWidth = useTerminalStateStore((state) => state.setTerminalWidth); const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal); + const storeSetTerminalOpen = useTerminalStateStore((state) => state.setTerminalOpen); const [localFocusRequestId, setLocalFocusRequestId] = useState(0); + const floatingTerminalTitleId = useId(); + + const [floatingWidth, setFloatingWidth] = useState(() => + clampFloatingTerminalWidth(terminalState.terminalWidth), + ); + const floatingWidthRef = useRef(floatingWidth); + const widthResizeStateRef = useRef<{ + pointerId: number; + side: "left" | "right"; + startX: number; + startWidth: number; + } | null>(null); + const didWidthResizeDuringDragRef = useRef(false); + const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const effectiveWorktreePath = useMemo(() => { if (launchContext !== null) { @@ -513,6 +544,87 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra [storeSetTerminalHeight, threadRef], ); + const setTerminalWidth = useCallback( + (width: number) => { + storeSetTerminalWidth(threadRef, width); + }, + [storeSetTerminalWidth, threadRef], + ); + + useEffect(() => { + floatingWidthRef.current = floatingWidth; + }, [floatingWidth]); + + useEffect(() => { + if (widthResizeStateRef.current) return; + const clamped = clampFloatingTerminalWidth(terminalState.terminalWidth); + floatingWidthRef.current = clamped; + setFloatingWidth(clamped); + }, [terminalState.terminalWidth, threadId]); + + const handleWidthResizePointerDownLeft = useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + didWidthResizeDuringDragRef.current = false; + widthResizeStateRef.current = { + pointerId: event.pointerId, + side: "left", + startX: event.clientX, + startWidth: floatingWidthRef.current, + }; + }, + [], + ); + + const handleWidthResizePointerDownRight = useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + didWidthResizeDuringDragRef.current = false; + widthResizeStateRef.current = { + pointerId: event.pointerId, + side: "right", + startX: event.clientX, + startWidth: floatingWidthRef.current, + }; + }, + [], + ); + + const handleWidthResizePointerMove = useCallback( + (event: React.PointerEvent) => { + const state = widthResizeStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + event.preventDefault(); + const delta = event.clientX - state.startX; + const rawWidth = + state.side === "right" ? state.startWidth + delta : state.startWidth - delta; + const clamped = clampFloatingTerminalWidth(rawWidth); + if (clamped === floatingWidthRef.current) return; + didWidthResizeDuringDragRef.current = true; + floatingWidthRef.current = clamped; + setFloatingWidth(clamped); + }, + [], + ); + + const handleWidthResizePointerEnd = useCallback( + (event: React.PointerEvent) => { + const state = widthResizeStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + widthResizeStateRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (!didWidthResizeDuringDragRef.current) return; + setTerminalWidth(floatingWidthRef.current); + }, + [setTerminalWidth], + ); + const splitTerminal = useCallback(() => { storeSplitTerminal(threadRef, `terminal-${randomUUID()}`); bumpFocusRequestId(); @@ -569,39 +681,98 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }, [onAddTerminalContext, visible], ); + const closeTerminalWindow = useCallback(() => { + storeSetTerminalOpen(threadRef, false); + }, [storeSetTerminalOpen, threadRef]); if (!project || !terminalState.terminalOpen || !cwd) { return null; } - return ( -
- -
- ); + const drawer = ( + + ); + + if (terminalLayout === "floating") { + return ( +
{ + if (event.target === event.currentTarget) { + closeTerminalWindow(); + } + }} + > +
+ {/* Left resize handle */} +
+ {/* Right resize handle */} +
+
+

+ Terminal +

+ +
+ {drawer} +
+
+ ); + } + + return
{drawer}
; }); export default function ChatView(props: ChatViewProps) { @@ -3527,6 +3698,7 @@ export default function ChatView(props: ChatViewProps) { availableEditors={availableEditors} terminalAvailable={activeProject !== undefined} terminalOpen={terminalState.terminalOpen} + terminalLayout={settings.terminalLayout} terminalToggleShortcutLabel={terminalToggleShortcutLabel} diffToggleShortcutLabel={diffPanelShortcutLabel} gitCwd={gitCwd} @@ -3753,6 +3925,7 @@ export default function ChatView(props: ChatViewProps) { newShortcutLabel={newTerminalShortcutLabel ?? undefined} closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} keybindings={keybindings} + terminalLayout={settings.terminalLayout} onAddTerminalContext={addTerminalContextToDraft} /> ))} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6c71e5eb334..e76bb198cb7 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -3,6 +3,7 @@ import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "luci import { type ResolvedKeybindingsConfig, type ScopedThreadRef, + type TerminalLayout, type TerminalEvent, type TerminalSessionSnapshot, type ThreadId, @@ -20,6 +21,7 @@ import { } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { type TerminalContextSelection } from "~/lib/terminalContext"; +import { cn } from "~/lib/utils"; import { openInPreferredEditor } from "../editorPreferences"; import { collectWrappedTerminalLinkLine, @@ -50,17 +52,18 @@ import { readLocalApi } from "~/localApi"; import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; const MIN_DRAWER_HEIGHT = 180; -const MAX_DRAWER_HEIGHT_RATIO = 0.75; +const DEFAULT_MAX_DRAWER_HEIGHT_RATIO = 0.75; +const FLOATING_MAX_DRAWER_HEIGHT_RATIO = 0.92; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; -function maxDrawerHeight(): number { +function maxDrawerHeight(ratio = DEFAULT_MAX_DRAWER_HEIGHT_RATIO): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; - return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO)); + return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * ratio)); } -function clampDrawerHeight(height: number): number { +function clampDrawerHeight(height: number, ratio = DEFAULT_MAX_DRAWER_HEIGHT_RATIO): number { const safeHeight = Number.isFinite(height) ? height : DEFAULT_THREAD_TERMINAL_HEIGHT; - const maxHeight = maxDrawerHeight(); + const maxHeight = maxDrawerHeight(ratio); return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight); } @@ -821,6 +824,7 @@ interface ThreadTerminalDrawerProps { onHeightChange: (height: number) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; keybindings: ResolvedKeybindingsConfig; + layout?: TerminalLayout; } interface TerminalActionButtonProps { @@ -875,11 +879,16 @@ export default function ThreadTerminalDrawer({ onHeightChange, onAddTerminalContext, keybindings, + layout = "docked", }: ThreadTerminalDrawerProps) { - const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); + const maxHeightRatio = + layout === "floating" ? FLOATING_MAX_DRAWER_HEIGHT_RATIO : DEFAULT_MAX_DRAWER_HEIGHT_RATIO; + const maxHeightRatioRef = useRef(maxHeightRatio); + maxHeightRatioRef.current = maxHeightRatio; + const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height, maxHeightRatio)); const [resizeEpoch, setResizeEpoch] = useState(0); const drawerHeightRef = useRef(drawerHeight); - const lastSyncedHeightRef = useRef(clampDrawerHeight(height)); + const lastSyncedHeightRef = useRef(clampDrawerHeight(height, maxHeightRatio)); const onHeightChangeRef = useRef(onHeightChange); const resizeStateRef = useRef<{ pointerId: number; @@ -1016,14 +1025,14 @@ export default function ThreadTerminalDrawer({ }, [drawerHeight]); const syncHeight = useCallback((nextHeight: number) => { - const clampedHeight = clampDrawerHeight(nextHeight); + const clampedHeight = clampDrawerHeight(nextHeight, maxHeightRatioRef.current); if (lastSyncedHeightRef.current === clampedHeight) return; lastSyncedHeightRef.current = clampedHeight; onHeightChangeRef.current(clampedHeight); }, []); useEffect(() => { - const clampedHeight = clampDrawerHeight(height); + const clampedHeight = clampDrawerHeight(height, maxHeightRatioRef.current); setDrawerHeight(clampedHeight); drawerHeightRef.current = clampedHeight; lastSyncedHeightRef.current = clampedHeight; @@ -1047,6 +1056,7 @@ export default function ThreadTerminalDrawer({ event.preventDefault(); const clampedHeight = clampDrawerHeight( resizeState.startHeight + (resizeState.startY - event.clientY), + maxHeightRatioRef.current, ); if (clampedHeight === drawerHeightRef.current) { return; @@ -1079,7 +1089,7 @@ export default function ThreadTerminalDrawer({ } const onWindowResize = () => { - const clampedHeight = clampDrawerHeight(drawerHeightRef.current); + const clampedHeight = clampDrawerHeight(drawerHeightRef.current, maxHeightRatioRef.current); const changed = clampedHeight !== drawerHeightRef.current; if (changed) { setDrawerHeight(clampedHeight); @@ -1111,7 +1121,10 @@ export default function ThreadTerminalDrawer({ return (