-
Notifications
You must be signed in to change notification settings - Fork 2.5k
feat: adds floating terminal layout #2344
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<HTMLDivElement>) => { | ||
| 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<HTMLDivElement>) => { | ||
| 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<HTMLDivElement>) => { | ||
| 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<HTMLDivElement>) => { | ||
| 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 ( | ||
| <div className={visible ? undefined : "hidden"}> | ||
| <ThreadTerminalDrawer | ||
| threadRef={threadRef} | ||
| threadId={threadId} | ||
| cwd={cwd} | ||
| worktreePath={effectiveWorktreePath} | ||
| runtimeEnv={runtimeEnv} | ||
| visible={visible} | ||
| height={terminalState.terminalHeight} | ||
| terminalIds={terminalState.terminalIds} | ||
| activeTerminalId={terminalState.activeTerminalId} | ||
| terminalGroups={terminalState.terminalGroups} | ||
| activeTerminalGroupId={terminalState.activeTerminalGroupId} | ||
| focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} | ||
| onSplitTerminal={splitTerminal} | ||
| onNewTerminal={createNewTerminal} | ||
| splitShortcutLabel={visible ? splitShortcutLabel : undefined} | ||
| newShortcutLabel={visible ? newShortcutLabel : undefined} | ||
| closeShortcutLabel={visible ? closeShortcutLabel : undefined} | ||
| keybindings={keybindings} | ||
| onActiveTerminalChange={activateTerminal} | ||
| onCloseTerminal={closeTerminal} | ||
| onHeightChange={setTerminalHeight} | ||
| onAddTerminalContext={handleAddTerminalContext} | ||
| /> | ||
| </div> | ||
| ); | ||
| const drawer = ( | ||
| <ThreadTerminalDrawer | ||
| threadRef={threadRef} | ||
| threadId={threadId} | ||
| cwd={cwd} | ||
| worktreePath={effectiveWorktreePath} | ||
| runtimeEnv={runtimeEnv} | ||
| visible={visible} | ||
| height={terminalState.terminalHeight} | ||
| terminalIds={terminalState.terminalIds} | ||
| activeTerminalId={terminalState.activeTerminalId} | ||
| terminalGroups={terminalState.terminalGroups} | ||
| activeTerminalGroupId={terminalState.activeTerminalGroupId} | ||
| focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} | ||
| onSplitTerminal={splitTerminal} | ||
| onNewTerminal={createNewTerminal} | ||
| splitShortcutLabel={visible ? splitShortcutLabel : undefined} | ||
| newShortcutLabel={visible ? newShortcutLabel : undefined} | ||
| closeShortcutLabel={visible ? closeShortcutLabel : undefined} | ||
| keybindings={keybindings} | ||
| onActiveTerminalChange={activateTerminal} | ||
| onCloseTerminal={closeTerminal} | ||
| onHeightChange={setTerminalHeight} | ||
| onAddTerminalContext={handleAddTerminalContext} | ||
| layout={terminalLayout} | ||
| /> | ||
| ); | ||
|
|
||
| if (terminalLayout === "floating") { | ||
| return ( | ||
| <div | ||
| className={cn( | ||
| "fixed inset-0 z-50 bg-black/32 backdrop-blur-sm", | ||
| visible ? "flex items-center justify-center p-3" : "hidden", | ||
| )} | ||
| onMouseDown={(event) => { | ||
| if (event.target === event.currentTarget) { | ||
| closeTerminalWindow(); | ||
| } | ||
| }} | ||
| > | ||
| <div | ||
| role="dialog" | ||
| aria-modal="true" | ||
| aria-labelledby={floatingTerminalTitleId} | ||
| className="relative overflow-hidden rounded-lg border bg-background p-0 shadow-xl" | ||
| style={{ width: `${floatingWidth}px` }} | ||
| > | ||
| {/* Left resize handle */} | ||
| <div | ||
| className="absolute inset-y-0 left-0 z-20 w-1.5 cursor-col-resize" | ||
| onPointerDown={handleWidthResizePointerDownLeft} | ||
| onPointerMove={handleWidthResizePointerMove} | ||
| onPointerUp={handleWidthResizePointerEnd} | ||
| onPointerCancel={handleWidthResizePointerEnd} | ||
| /> | ||
| {/* Right resize handle */} | ||
| <div | ||
| className="absolute inset-y-0 right-0 z-20 w-1.5 cursor-col-resize" | ||
| onPointerDown={handleWidthResizePointerDownRight} | ||
| onPointerMove={handleWidthResizePointerMove} | ||
| onPointerUp={handleWidthResizePointerEnd} | ||
| onPointerCancel={handleWidthResizePointerEnd} | ||
| /> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Centered modal breaks edge resizeMedium Severity The floating terminal is centered with flexbox while width changes use edge-drag math that assumes a fixed anchor. Resizing from the left or right handle moves both edges symmetrically, so the handles do not track the pointer and width changes feel wrong. Reviewed by Cursor Bugbot for commit 9090da8. Configure here. |
||
| <div className="flex h-8 shrink-0 items-center justify-between border-b border-border/80 px-2"> | ||
| <h2 id={floatingTerminalTitleId} className="text-xs font-medium leading-none"> | ||
| Terminal | ||
| </h2> | ||
| <button | ||
| type="button" | ||
| className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" | ||
| onClick={closeTerminalWindow} | ||
| aria-label="Close terminal window" | ||
| > | ||
| <XIcon className="size-3.5" /> | ||
| </button> | ||
| </div> | ||
| {drawer} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return <div className={visible ? undefined : "hidden"}>{drawer}</div>; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| 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} | ||
| /> | ||
| ))} | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Floating width not viewport reclamped
Medium Severity
Floating terminal width is clamped during drag and when persisted width changes, but there is no window
resizehandler. After shrinking the browser, a stored width above 97% of the new viewport can leave the dialog wider than the screen until the user drags again.Reviewed by Cursor Bugbot for commit 9090da8. Configure here.