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]);

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}
/>
<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