Skip to content
Closed
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
17 changes: 17 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1728,6 +1728,23 @@ describe("session branch mismatch dismissal", () => {
});

describe("reconcileMountedTerminalThreadIds", () => {
it("retains a closed active drawer until navigation without mounting unopened drawers", () => {
const closed = reconcileMountedTerminalThreadIds({
currentThreadIds: ["thread-a"],
openThreadIds: [],
activeThreadId: "thread-a",
activeThreadTerminalOpen: false,
});
expect(closed).toEqual(["thread-a"]);
expect(
reconcileMountedTerminalThreadIds({
currentThreadIds: closed,
openThreadIds: [],
activeThreadId: "thread-b",
activeThreadTerminalOpen: false,
}),
).toEqual([]);
});
it("keeps open threads and makes the active thread most recent", () => {
expect(
reconcileMountedTerminalThreadIds({
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,9 +676,14 @@ export function reconcileMountedTerminalThreadIds(input: {
? hiddenThreadIds.slice(-maxHiddenThreadCount)
: hiddenThreadIds;

// Keep the active drawer mounted after closing so CSS can finish its
// transition and reopening can reuse the viewport. Navigation releases it.
const activeThreadMounted =
input.activeThreadTerminalOpen ||
(input.activeThreadId !== null && input.currentThreadIds.includes(input.activeThreadId));
if (
input.activeThreadId &&
input.activeThreadTerminalOpen &&
activeThreadMounted &&
!nextThreadIds.includes(input.activeThreadId)
) {
nextThreadIds.push(input.activeThreadId);
Expand Down
97 changes: 66 additions & 31 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,11 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";
import { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic";
import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout";
import { ChatHeader } from "./chat/ChatHeader";
import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
import {
PanelLayoutControls,
RightPanelMaximizeControl,
TerminalDrawerToggle,
} from "./chat/PanelLayoutControls";
import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
import { NoActiveThreadState } from "./NoActiveThreadState";
import { WorkspacePageHeader } from "./WorkspacePageHeader";
Expand Down Expand Up @@ -926,6 +930,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef),
);
const visible = active && terminalUiState.terminalOpen;
const { active: animateDrawer, durationMs: drawerAnimationDurationMs } =
Comment thread
saphid marked this conversation as resolved.
usePanelAnimationSettings(200);
const knownTerminalSessions = useKnownTerminalSessions({
environmentId: threadRef.environmentId,
threadId,
Expand Down Expand Up @@ -1219,10 +1225,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
className={cn(
"grid shrink-0 overflow-clip",
active ? (visible ? "grid-rows-[1fr]" : "grid-rows-[0fr]") : "hidden",
active &&
"[[data-panel-animations=true]_&]:transition-[grid-template-rows] [[data-panel-animations=true]_&]:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:ease-out",
active && visible && "[[data-panel-animations=true]_&]:starting:grid-rows-[0fr]!",
active && animateDrawer && "transition-[grid-template-rows] ease-out",
active && animateDrawer && visible && "starting:grid-rows-[0fr]!",
)}
style={{ transitionDuration: animateDrawer ? `${drawerAnimationDurationMs}ms` : undefined }}
inert={!visible}
>
<div className="min-h-0 overflow-clip">
<ThreadTerminalDrawer
Expand Down Expand Up @@ -2046,13 +2053,6 @@ export default function ChatView(props: ChatViewProps) {
const rightPanelOpen = rightPanelState.isOpen;
const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
usePanelAnimationSettings();
const activeTerminalDrawerPresence = usePanelPresence(
Boolean(activeThreadKey && terminalUiState.terminalOpen),
true,
panelAnimationsActive,
activeThreadKey,
panelAnimationDurationMs,
);
const rightPanelPresenceValue = useMemo(
() => ({
activeSurface: activeRightPanelSurface,
Expand Down Expand Up @@ -2130,15 +2130,15 @@ export default function ChatView(props: ChatViewProps) {
currentThreadIds,
openThreadIds: existingOpenTerminalThreadKeys,
activeThreadId: activeThreadKey,
activeThreadTerminalOpen: activeTerminalDrawerPresence.present,
activeThreadTerminalOpen: terminalUiState.terminalOpen,
maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
});
return currentThreadIds.length === nextThreadIds.length &&
currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index])
? currentThreadIds
: nextThreadIds;
});
}, [activeTerminalDrawerPresence.present, activeThreadKey, existingOpenTerminalThreadKeys]);
}, [terminalUiState.terminalOpen, activeThreadKey, existingOpenTerminalThreadKeys]);
const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
const activeProjectRef = useMemo(
() =>
Expand Down Expand Up @@ -9573,8 +9573,26 @@ export default function ChatView(props: ChatViewProps) {
return <NoActiveThreadState />;
}

const terminalDrawerControls = (
<div
className="absolute right-[calc(env(safe-area-inset-right)+0.75rem)] bottom-full z-30"
data-terminal-drawer-controls
>
<TerminalDrawerToggle
attached
terminalAvailable={activeProject !== null}
terminalOpen={terminalUiState.terminalOpen}
terminalShortcutLabel={shortcutLabelForCommand(keybindings, "terminal.toggle")}
onToggleTerminal={() => {
if (shouldUseRightPanelSheet && rightPanelOpen) closePreviewPanel();
toggleTerminalVisibility();
}}
/>
</div>
);
const panelToggleControls = (
<PanelLayoutControls
showTerminalControl={rightPanelMaximized}
terminalAvailable={activeProject !== null}
terminalOpen={terminalUiState.terminalOpen}
terminalShortcutLabel={shortcutLabelForCommand(keybindings, "terminal.toggle")}
Expand Down Expand Up @@ -10326,24 +10344,38 @@ export default function ChatView(props: ChatViewProps) {
</div>
{/* end horizontal flex container */}

{mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => (
<PersistentThreadTerminalDrawer
key={mountedThreadKey}
threadRef={mountedThreadRef}
threadId={mountedThreadRef.threadId}
active={mountedThreadKey === activeThreadKey}
launchContext={
mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null
}
focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0}
splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined}
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
keybindings={keybindings}
onAddTerminalContext={addTerminalContextToDraft}
/>
))}
<div
className="relative shrink-0"
Comment thread
saphid marked this conversation as resolved.
// Maximizing the inline right panel collapses this column to w-0
// with clipped overflow; keep the toggle out of the tab order
// instead of leaving an invisible, focusable control behind.
inert={rightPanelMaximized || undefined}
>
{!(rightPanelOpen && shouldUseRightPanelSheet && activeThreadRef) &&
terminalDrawerControls}
{mountedTerminalThreadRefs.map(
({ key: mountedThreadKey, threadRef: mountedThreadRef }) => (
<PersistentThreadTerminalDrawer
key={mountedThreadKey}
threadRef={mountedThreadRef}
threadId={mountedThreadRef.threadId}
active={mountedThreadKey === activeThreadKey}
launchContext={
mountedThreadKey === activeThreadKey
? (activeTerminalLaunchContext ?? null)
: null
}
focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0}
splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined}
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
keybindings={keybindings}
onAddTerminalContext={addTerminalContextToDraft}
/>
),
)}
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>

{rightPanelPresent && !shouldUseRightPanelSheet && activeThreadRef ? (
Expand Down Expand Up @@ -10448,6 +10480,9 @@ export default function ChatView(props: ChatViewProps) {
>
{rightPanelContent}
</RightPanelTabs>
<div className="absolute inset-x-0 bottom-[env(safe-area-inset-bottom)]">
{terminalDrawerControls}
</div>
</RightPanelSheet>
) : null}

Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,18 @@ export function TerminalViewport({
reportFailure: false,
});
const hasHandledExitRef = useRef(false);
// Retained drawers stay mounted while closed, so a failed libghostty setup
// no longer remounts on reopen. Retry the setup the next time the viewport
// becomes visible to keep "close and reopen the terminal" a working
// recovery path for the failure message below.
const [setupAttempt, setSetupAttempt] = useState(0);
const setupFailedRef = useRef(false);
useEffect(() => {
if (!visible || !setupFailedRef.current) return;
setupFailedRef.current = false;
if (containerRef.current) containerRef.current.textContent = "";
setSetupAttempt((attempt) => attempt + 1);
}, [visible]);
const selectionActionRequestIdRef = useRef(0);
// Holds the request id of the selection popup currently on screen, so a
// popup that was superseded (but whose menu promise has not settled yet)
Expand Down Expand Up @@ -909,6 +921,7 @@ export function TerminalViewport({
setupTerminal?.dispose();
setupTerminal = null;
if (cancelled) return;
setupFailedRef.current = true;
const message =
error instanceof Error ? error.message : "Unable to initialize libghostty-vt";
mount.textContent = `${message} — close and reopen the terminal to retry.`;
Expand All @@ -920,7 +933,7 @@ export function TerminalViewport({
teardown?.();
if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true });
};
}, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);
}, [cwd, environmentId, runtimeEnvKey, setupAttempt, terminalId, threadId, worktreePath]);

useEffect(() => {
const terminal = terminalRef.current;
Expand Down
64 changes: 43 additions & 21 deletions apps/web/src/components/chat/PanelLayoutControls.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react";
import { Maximize2Icon, Minimize2Icon, SquareTerminalIcon, PanelRightIcon } from "lucide-react";
import { memo } from "react";

// Reuses the project Toggle; the attached drawer treatment is a local variant.
// https://ui.shadcn.com/docs/components/base/toggle
import { Toggle } from "../ui/toggle";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";

Expand All @@ -19,6 +21,40 @@ interface PanelLayoutControlsProps {
onToggleRightPanel: () => void;
}

export const TerminalDrawerToggle = memo(function TerminalDrawerToggle({
attached = false,
terminalAvailable,
terminalOpen,
terminalShortcutLabel,
onToggleTerminal,
}: Pick<
PanelLayoutControlsProps,
"terminalAvailable" | "terminalOpen" | "terminalShortcutLabel" | "onToggleTerminal"
> & { attached?: boolean }) {
return (
<Tooltip>
<TooltipTrigger render={<span className="flex shrink-0" />}>
<Toggle
className="[-webkit-app-region:no-drag]"
pressed={terminalOpen}
onPressedChange={onToggleTerminal}
aria-label="Toggle terminal drawer"
variant={attached ? "drawer" : "ghost"}
size={attached ? "drawer" : "sm"}
disabled={!terminalAvailable}
>
<SquareTerminalIcon className="size-4" />
</Toggle>
</TooltipTrigger>
<TooltipPopup side="top">
{terminalAvailable
? `Toggle terminal drawer${terminalShortcutLabel ? ` (${terminalShortcutLabel})` : ""}`
: "Terminal drawer is unavailable"}
</TooltipPopup>
</Tooltip>
);
});

export const PanelLayoutControls = memo(function PanelLayoutControls({
showTerminalControl = true,
terminalAvailable,
Expand All @@ -38,26 +74,12 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
data-panel-layout-controls
>
{showTerminalControl ? (
<Tooltip>
<TooltipTrigger render={<span className="flex shrink-0" />}>
<Toggle
className="shrink-0 [-webkit-app-region:no-drag]"
pressed={terminalOpen}
onPressedChange={onToggleTerminal}
aria-label="Toggle terminal drawer"
variant="ghost"
size="sm"
disabled={!terminalAvailable}
>
<PanelBottomIcon className="size-4" />
</Toggle>
</TooltipTrigger>
<TooltipPopup side="bottom">
{terminalAvailable
? `Toggle terminal drawer${terminalShortcutLabel ? ` (${terminalShortcutLabel})` : ""}`
: "Terminal drawer is unavailable"}
</TooltipPopup>
</Tooltip>
<TerminalDrawerToggle
terminalAvailable={terminalAvailable}
terminalOpen={terminalOpen}
terminalShortcutLabel={terminalShortcutLabel}
onToggleTerminal={onToggleTerminal}
/>
) : null}
<Tooltip>
<TooltipTrigger render={<span className="flex shrink-0" />}>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1388,7 +1388,7 @@ export function AppearanceSettingsPanel() {
<SettingsSection id="motion" title="Motion">
<SettingsRow
{...searchableSetting("panel-animations")}
description="Set how fast panels open and close."
description="Set how fast panels open and close. The terminal drawer uses at least 200 ms. Reduced motion disables panel animations."
control={
<div className="grid w-full grid-cols-[5rem_minmax(0,1fr)] items-center gap-3 sm:w-auto sm:grid-cols-[7rem_13rem] sm:gap-4">
<PanelAnimationsPreview durationMs={settings.panelAnimationDurationMs} />
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/ui/toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const toggleVariants = cva(
},
variants: {
size: {
drawer: "h-8 w-10 px-[calc(--spacing(1.5)-1px)]",
compact:
"h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5",
default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8",
Expand All @@ -24,6 +25,8 @@ const toggleVariants = cva(
xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",
},
variant: {
drawer:
"rounded-t-lg rounded-b-none border-b-0 border-border bg-muted shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100",
default: "border-transparent",
pill: "border-transparent rounded-full px-3 font-normal text-muted-foreground data-pressed:text-foreground",
ghost:
Expand Down
Loading
Loading