Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1911,6 +1911,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
[THREAD_KEY]: {
terminalOpen: true,
terminalHeight: 280,
terminalWidth: 900,
terminalIds: ["default"],
runningTerminalIds: [],
activeTerminalId: "default",
Expand Down
233 changes: 203 additions & 30 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type ServerProvider,
type ResolvedKeybindingsConfig,
type ScopedThreadRef,
type TerminalLayout,
type ThreadId,
type TurnId,
type KeybindingCommand,
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -438,6 +451,7 @@ interface PersistentThreadTerminalDrawerProps {
newShortcutLabel: string | undefined;
closeShortcutLabel: string | undefined;
keybindings: ResolvedKeybindingsConfig;
terminalLayout: TerminalLayout;
onAddTerminalContext: (selection: TerminalContextSelection) => void;
}

Expand All @@ -451,6 +465,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
newShortcutLabel,
closeShortcutLabel,
keybindings,
terminalLayout,
onAddTerminalContext,
}: PersistentThreadTerminalDrawerProps) {
const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
Expand All @@ -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) {
Expand Down Expand Up @@ -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]);

Copy link
Copy Markdown
Contributor

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 resize handler. 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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9090da8. Configure here.


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();
Expand Down Expand Up @@ -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}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centered modal breaks edge resize

Medium 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.

Fix in Cursor Fix in Web

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>;
Comment thread
cursor[bot] marked this conversation as resolved.
});

export default function ChatView(props: ChatViewProps) {
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -3753,6 +3925,7 @@ export default function ChatView(props: ChatViewProps) {
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
keybindings={keybindings}
terminalLayout={settings.terminalLayout}
onAddTerminalContext={addTerminalContextToDraft}
/>
))}
Expand Down
Loading
Loading