feat: support multiple terminal tabs on the agents right panel (#26089)

- Adds a dynamic terminal tab model to the Coder Agents right panel so
users can open, switch between, and close multiple Web Terminal sessions
for the same agent.
- Each terminal tab generates its own UUID reconnect token and is
persisted per agent in local storage, so tabs and their PTY sessions
survive reloads.
- New terminal tabs are auto-labelled (`Terminal 2`, `Terminal 3`, ...),
filling the lowest free number.
- The built-in Terminal tab is now closeable; closing it hides it and
persists that choice per agent, and it can be restored from the add-tab
control.
- Only the active terminal (and a tab that is mid-activation) mounts the
expensive xterm/WebSocket/PTY resources; a freshly hidden terminal stays
warm for 30s to keep quick tab toggles instant, then detaches to bound
resource usage instead of capping the tab count.
- Inactive terminals stay laid out but hidden, and a newly opened
terminal is not activated until it reports ready (or 100ms), avoiding
the narrow refit and blank-frame flicker when switching between terminal
canvases.
- The right-panel tab bar scrolls horizontally with chevron controls
when tabs overflow, keeping everything on a single row.
- Per-agent right-panel tab and hidden-terminal state is cleared from
local storage when a chat is archived or deleted.
- Adds unit tests for the tab utilities, persistence helpers, and the
warm/detach lifecycle hook, plus Storybook play-function coverage for
the tab and terminal components.

Relates to CODAGT-346
This commit is contained in:
Ethan
2026-06-10 00:08:01 +10:00
committed by GitHub
parent 9408b9d89b
commit db00007605
11 changed files with 830 additions and 106 deletions
@@ -42,6 +42,7 @@ type WorkspaceTerminalProps = {
containerUser?: string;
onStatusChange?: (status: ConnectionStatus) => void;
onError?: (error: Error) => void;
onContentReady?: () => void;
reconnectionToken: string;
baseUrl?: string;
terminalFontFamily?: string;
@@ -72,6 +73,7 @@ export const WorkspaceTerminal = ({
containerUser,
onStatusChange,
onError,
onContentReady,
reconnectionToken,
baseUrl,
terminalFontFamily = DEFAULT_TERMINAL_FONT_FAMILY,
@@ -92,14 +94,12 @@ export const WorkspaceTerminal = ({
const handleStatusChange = useEffectEvent((status: ConnectionStatus) => {
onStatusChange?.(status);
});
const handleContentReady = useEffectEvent(() => {
onContentReady?.();
});
const [terminal, setTerminal] = useState<Terminal>();
const { copyToClipboard } = useClipboard();
const [hasBeenVisible, setHasBeenVisible] = useState(false);
if (isVisible && !hasBeenVisible) {
setHasBeenVisible(true);
}
const reportTerminalError = useEffectEvent((error: Error) => {
console.error(error);
onError?.(error);
@@ -130,6 +130,16 @@ export const WorkspaceTerminal = ({
return;
}
// Fitting a zero-size container clamps the terminal and PTY to the minimum column count.
const mountNode = terminalWrapperRef.current;
if (
!mountNode ||
mountNode.clientWidth === 0 ||
mountNode.clientHeight === 0
) {
return;
}
// We have to fit twice here. It's unknown why, but the
// first fit will overflow slightly in some scenarios.
// Applying a second fit resolves this.
@@ -151,7 +161,7 @@ export const WorkspaceTerminal = ({
);
useEffect(() => {
if (!hasBeenVisible) {
if (!isVisible) {
return;
}
@@ -265,7 +275,7 @@ export const WorkspaceTerminal = ({
setTerminal(undefined);
};
}, [
hasBeenVisible,
isVisible,
copyToClipboard,
refit,
renderer,
@@ -295,8 +305,34 @@ export const WorkspaceTerminal = ({
};
}, [terminal, isVisible, autoFocus, loading]);
// Notify after first output paints so consumers can hide connection latency.
useEffect(() => {
if (!terminal || !hasBeenVisible) {
if (!terminal) {
return;
}
let hasParsedOutput = false;
const writeParsed = terminal.onWriteParsed(() => {
hasParsedOutput = true;
});
// onWriteParsed fires before xterm paints; gate on the next onRender so
// pixels are present before the terminal is revealed. clear()/refresh
// fire onRender without a parse and are intentionally ignored.
const rendered = terminal.onRender(() => {
if (!hasParsedOutput) {
return;
}
writeParsed.dispose();
rendered.dispose();
handleContentReady();
});
return () => {
writeParsed.dispose();
rendered.dispose();
};
}, [terminal]);
useEffect(() => {
if (!terminal || !isVisible) {
return;
}
@@ -469,7 +505,7 @@ export const WorkspaceTerminal = ({
websocketRef.current = undefined;
};
}, [
hasBeenVisible,
isVisible,
agentId,
baseUrl,
containerName,
@@ -1602,6 +1602,7 @@ const AgentChatPage: FC = () => {
return (
<AgentChatPageView
key={agentId}
agentId={agentId}
sendShortcut={getAgentChatSendShortcut(
preferencesQuery.data?.agent_chat_send_shortcut,
+200 -13
View File
@@ -1,14 +1,16 @@
import { ArchiveIcon, TriangleAlertIcon } from "lucide-react";
import { ArchiveIcon, PlusIcon, TriangleAlertIcon } from "lucide-react";
import {
type FC,
type ReactNode,
type RefObject,
useEffect,
useRef,
useState,
} from "react";
import { useQueryClient } from "react-query";
import type { UrlTransform } from "streamdown";
import { v4 as uuidv4 } from "uuid";
import { chatDiffContentsKey } from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
import type {
@@ -16,6 +18,7 @@ import type {
ChatDiffStatus,
ChatMessagePart,
} from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { cn } from "#/utils/cn";
import { pageTitle } from "#/utils/page";
import {
@@ -43,6 +46,13 @@ import { getWorkspaceStatus, StatusIcon } from "./components/StatusIcon";
import { TerminalPanel } from "./components/TerminalPanel";
import { ChatWorkspaceContext } from "./context/ChatWorkspaceContext";
import { chatWidthClass, useChatFullWidth } from "./hooks/useChatFullWidth";
import {
getPersistedDefaultTerminalHidden,
getPersistedRightPanelTabs,
savePersistedDefaultTerminalHidden,
savePersistedRightPanelTabs,
} from "./utils/rightPanelTabStorage";
import type { UserRightPanelTab } from "./utils/rightPanelTabs";
import {
getPersistedSidebarTabId,
savePersistedSidebarTabId,
@@ -196,6 +206,41 @@ interface AgentChatPageViewProps {
lastInjectedContext?: readonly TypesGen.ChatMessagePart[];
}
interface UserTerminalTabContentProps {
tab: UserRightPanelTab;
chatId: string;
workspace: TypesGen.Workspace;
workspaceAgent: TypesGen.WorkspaceAgent;
activeTabId: string | null;
pendingTabId: string | null;
isPanelVisible: boolean;
onReady: (tabId: string) => void;
}
const UserTerminalTabContent: FC<UserTerminalTabContentProps> = ({
tab,
chatId,
workspace,
workspaceAgent,
activeTabId,
pendingTabId,
isPanelVisible,
onReady,
}) => {
const isActive = activeTabId === tab.id;
return (
<TerminalPanel
chatId={chatId}
reconnectionToken={tab.reconnectionToken}
isHot={isPanelVisible && (isActive || pendingTabId === tab.id)}
autoFocus={isPanelVisible && isActive}
onReady={() => onReady(tab.id)}
workspace={workspace}
workspaceAgent={workspaceAgent}
/>
);
};
export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
agentId,
sendShortcut,
@@ -299,6 +344,12 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
const [sidebarTabId, setSidebarTabIdState] = useState<string | null>(() =>
getPersistedSidebarTabId(agentId),
);
const [userRightPanelTabs, setUserRightPanelTabsState] = useState<
UserRightPanelTab[]
>(() => getPersistedRightPanelTabs(agentId));
const [defaultTerminalHidden, setDefaultTerminalHiddenState] =
useState<boolean>(() => getPersistedDefaultTerminalHidden(agentId));
const [pendingTabId, setPendingTabId] = useState<string | null>(null);
const setSidebarTabId = (tabId: string) => {
setSidebarTabIdState(tabId);
@@ -307,8 +358,21 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
}
};
useEffect(() => {
if (!isArchived) {
savePersistedRightPanelTabs(agentId, userRightPanelTabs);
}
}, [agentId, isArchived, userRightPanelTabs]);
useEffect(() => {
if (!isArchived) {
savePersistedDefaultTerminalHidden(agentId, defaultTerminalHidden);
}
}, [agentId, defaultTerminalHidden, isArchived]);
const handleOpenDesktop = () => {
onSetShowSidebarPanel(true);
setPendingTabId(null);
setSidebarTabId("desktop");
};
@@ -355,16 +419,29 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
// picking "desktop" when no desktop panel is rendered.
const availableDesktopChatId =
workspace && workspaceAgent ? desktopChatId : undefined;
const visibleUserTabs = workspace && workspaceAgent ? userRightPanelTabs : [];
// Single source of truth for available tabs and their order. The list
// of tab IDs used by `getEffectiveTabId` is derived from this so a
// new tab can never be added to one without the other going out of
// sync.
const sidebarTabConfigs = [
const builtInSidebarTabConfigs = [
{ id: "git", label: "Git" },
...(workspace && workspaceAgent
...(debugLoggingEnabled ? [{ id: "debug", label: "Debug" }] : []),
...(workspace && workspaceAgent && !defaultTerminalHidden
? [{ id: "terminal", label: "Terminal" }]
: []),
...(debugLoggingEnabled ? [{ id: "debug", label: "Debug" }] : []),
];
const sidebarTabConfigs = [
...builtInSidebarTabConfigs,
...visibleUserTabs.map((tab, index) => {
const terminalNumber = index + (defaultTerminalHidden ? 1 : 2);
return {
id: tab.id,
label: terminalNumber === 1 ? "Terminal" : `Terminal ${terminalNumber}`,
};
}),
];
const sidebarTabIds = sidebarTabConfigs.map((tab) => tab.id);
const effectiveSidebarTabId = getEffectiveTabId(
@@ -372,6 +449,48 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
sidebarTabId,
availableDesktopChatId,
);
// Ignore late readiness from a tab the user already navigated past.
const handleTerminalTabReady = (tabId: string) => {
if (pendingTabId !== tabId) {
return;
}
setPendingTabId(null);
setSidebarTabId(tabId);
};
const handleActiveTabChange = (tabId: string) => {
setPendingTabId(null);
setSidebarTabId(tabId);
};
const startPendingTab = (tabId: string) => {
onSetShowSidebarPanel(true);
setPendingTabId(tabId);
};
const handleAddTerminalTab = () => {
if (!workspace || !workspaceAgent) {
return;
}
// Reopen the built-in Terminal instead of creating Terminal 2 with no Terminal 1.
if (defaultTerminalHidden) {
setDefaultTerminalHiddenState(false);
startPendingTab("terminal");
return;
}
const tabId = `terminal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setUserRightPanelTabsState((currentTabs) => [
...currentTabs,
{
id: tabId,
kind: "terminal",
reconnectionToken: uuidv4(),
},
]);
startPendingTab(tabId);
};
const renderTabContent = (tabId: string): ReactNode => {
switch (tabId) {
case "git":
@@ -397,9 +516,15 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
return workspace && workspaceAgent ? (
<TerminalPanel
chatId={agentId}
isVisible={
isHot={
shouldShowSidebar &&
(effectiveSidebarTabId === "terminal" ||
pendingTabId === "terminal")
}
autoFocus={
shouldShowSidebar && effectiveSidebarTabId === "terminal"
}
onReady={() => handleTerminalTabReady("terminal")}
workspace={workspace}
workspaceAgent={workspaceAgent}
/>
@@ -411,15 +536,64 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
isVisible={shouldShowSidebar && effectiveSidebarTabId === "debug"}
/>
);
default:
return null;
default: {
const userTab = visibleUserTabs.find((tab) => tab.id === tabId);
return userTab && workspace && workspaceAgent ? (
<UserTerminalTabContent
tab={userTab}
chatId={agentId}
workspace={workspace}
workspaceAgent={workspaceAgent}
activeTabId={effectiveSidebarTabId}
pendingTabId={pendingTabId}
isPanelVisible={shouldShowSidebar}
onReady={handleTerminalTabReady}
/>
) : null;
}
}
};
const sidebarTabs = sidebarTabConfigs.map((tab) => ({
id: tab.id,
label: tab.label,
content: renderTabContent(tab.id),
}));
const handleCloseTab = (tabId: string) => {
setPendingTabId((currentTabId) =>
currentTabId === tabId ? null : currentTabId,
);
const visibleTabIds = [
...sidebarTabIds,
...(availableDesktopChatId ? ["desktop"] : []),
];
const remainingTabIds = visibleTabIds.filter((id) => id !== tabId);
const closedTabIndex = visibleTabIds.indexOf(tabId);
if (tabId === "terminal") {
setDefaultTerminalHiddenState(true);
} else {
setUserRightPanelTabsState((currentTabs) =>
currentTabs.filter((tab) => tab.id !== tabId),
);
}
if (effectiveSidebarTabId !== tabId) {
return;
}
const nextActiveTabId =
remainingTabIds[Math.min(closedTabIndex, remainingTabIds.length - 1)];
if (nextActiveTabId) {
setSidebarTabId(nextActiveTabId);
}
};
const sidebarTabs = sidebarTabConfigs.map((tab) => {
const isCloseable =
tab.id === "terminal" ||
visibleUserTabs.some((userTab) => userTab.id === tab.id);
return {
id: tab.id,
label: tab.label,
content: renderTabContent(tab.id),
onClose: isCloseable ? () => handleCloseTab(tab.id) : undefined,
};
});
const isEditing =
editing.editingMessageId !== null ||
@@ -621,8 +795,21 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
>
<SidebarTabView
effectiveTabId={effectiveSidebarTabId}
onActiveTabChange={setSidebarTabId}
onActiveTabChange={handleActiveTabChange}
tabs={sidebarTabs}
addTabControl={
<Button
variant="outline"
size="icon"
onClick={handleAddTerminalTab}
disabled={!workspace || !workspaceAgent}
aria-label="New terminal tab"
title="New terminal tab"
className="size-6 bg-surface-primary p-0 text-content-secondary hover:text-content-primary"
>
<PlusIcon className="size-3.5" />
</Button>
}
onClose={() => onSetShowSidebarPanel(false)}
isExpanded={visualExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
+3
View File
@@ -64,6 +64,7 @@ import {
} from "./utils/agentWorkspaceUtils";
import { maybePlayChime } from "./utils/chime";
import { getModelOptionsFromConfigs } from "./utils/modelOptions";
import { clearPersistedRightPanelState } from "./utils/rightPanelTabStorage";
import { clearPersistedSidebarTabId } from "./utils/sidebarTabStorage";
import {
type ChatDetailError,
@@ -209,6 +210,7 @@ const AgentsPage: FC = () => {
onSuccess: (_data, chatId) => {
clearChatErrorReason(chatId);
clearPersistedSidebarTabId(chatId);
clearPersistedRightPanelState(chatId);
},
onError: (error, chatId, context) => {
archiveChatBase.onError(error, chatId, context);
@@ -232,6 +234,7 @@ const AgentsPage: FC = () => {
onSuccess: async ({ chatId }) => {
clearChatErrorReason(chatId);
clearPersistedSidebarTabId(chatId);
clearPersistedRightPanelState(chatId);
await invalidateChatListQueries(queryClient);
await queryClient.invalidateQueries({
queryKey: chatKey(chatId),
@@ -1,5 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { expect, fn, userEvent, within } from "storybook/test";
import { Button } from "#/components/Button/Button";
import type { SidebarTab } from "./SidebarTabView";
import { SidebarTabView } from "./SidebarTabView";
@@ -109,3 +112,134 @@ export const NarrowPanel: Story = {
),
],
};
export const CloseableTabs: Story = {
render: function CloseableTabs() {
const [activeTabId, setActiveTabId] = useState("terminal-2");
const [tabs, setTabs] = useState<SidebarTab[]>([
gitTab,
{
id: "terminal",
label: "Terminal",
content: makePanelContent("Terminal"),
},
{ id: "debug", label: "Debug", content: makePanelContent("Debug") },
...Array.from({ length: 8 }, (_, index) => ({
id: `terminal-${index + 2}`,
label: `Terminal ${index + 2}`,
content: makePanelContent(`Terminal ${index + 2}`),
})),
]);
const handleCloseTab = (tabId: string) => {
const visibleTabIds = tabs.map((tab) => tab.id);
const remainingTabIds = visibleTabIds.filter((id) => id !== tabId);
const closedTabIndex = visibleTabIds.indexOf(tabId);
setTabs(tabs.filter((tab) => tab.id !== tabId));
if (activeTabId !== tabId) {
return;
}
const nextActiveTabId =
remainingTabIds[Math.min(closedTabIndex, remainingTabIds.length - 1)];
if (nextActiveTabId) {
setActiveTabId(nextActiveTabId);
}
};
return (
<SidebarTabView
tabs={tabs.map((tab) => ({
...tab,
onClose: tab.id.startsWith("terminal-")
? () => handleCloseTab(tab.id)
: undefined,
}))}
effectiveTabId={activeTabId}
onActiveTabChange={setActiveTabId}
isExpanded={false}
onToggleExpanded={() => {}}
addTabControl={
<Button
variant="outline"
size="icon"
onClick={fn()}
aria-label="New terminal tab"
title="New terminal tab"
className="size-6 bg-surface-primary p-0 text-content-secondary hover:text-content-primary"
>
<PlusIcon className="size-3.5" />
</Button>
}
/>
);
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
expect(
canvas.queryByRole("button", { name: "Close Git tab" }),
).not.toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "Close Terminal tab" }),
).not.toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "Close Debug tab" }),
).not.toBeInTheDocument();
expect(canvas.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute(
"aria-selected",
"true",
);
await user.click(
canvas.getByRole("button", { name: "Close Terminal 3 tab" }),
);
expect(
canvas.queryByRole("tab", { name: "Terminal 3" }),
).not.toBeInTheDocument();
expect(canvas.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute(
"aria-selected",
"true",
);
await user.click(
canvas.getByRole("button", { name: "Close Terminal 2 tab" }),
);
expect(
canvas.queryByRole("tab", { name: "Terminal 2" }),
).not.toBeInTheDocument();
expect(canvas.getByRole("tab", { name: "Terminal 4" })).toHaveAttribute(
"aria-selected",
"true",
);
},
};
export const AddTabControlDisabled: Story = {
args: {
tabs: [gitTab],
addTabControl: (
<Button
variant="outline"
size="icon"
onClick={fn()}
disabled
aria-label="New terminal tab"
title="New terminal tab"
className="size-6 bg-surface-primary p-0 text-content-secondary hover:text-content-primary"
>
<PlusIcon className="size-3.5" />
</Button>
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("button", { name: "New terminal tab" }),
).toBeDisabled();
},
};
@@ -5,9 +5,17 @@ import {
MaximizeIcon,
MinimizeIcon,
PanelLeftIcon,
XIcon,
} from "lucide-react";
import type { ReactNode } from "react";
import { type FC, useEffect, useId, useRef, useState } from "react";
import {
type FC,
type ReactNode,
useEffect,
useEffectEvent,
useId,
useRef,
useState,
} from "react";
import { Button } from "#/components/Button/Button";
import { cn } from "#/utils/cn";
import { DesktopPanel } from "../../RightPanel/DesktopPanel";
@@ -19,10 +27,10 @@ export interface SidebarTab {
label: string;
/** Optional icon shown before the label. */
icon?: ReactNode;
/** Optional badge shown after the label (e.g. diff stats). */
badge?: ReactNode;
/** The content to render when this tab is active. */
content: ReactNode;
onClose?: () => void;
}
interface SidebarTabViewProps {
@@ -52,41 +60,49 @@ interface SidebarTabViewProps {
effectiveTabId: string | null;
/** Called when the user switches tabs. */
onActiveTabChange: (tabId: string) => void;
addTabControl?: ReactNode;
}
/** How far (px) each chevron click scrolls the tab strip. */
const TAB_SCROLL_AMOUNT = 120;
/**
* Tracks whether the tab scroll container overflows and
* exposes scroll helpers for the chevron buttons.
*/
function useTabScroll() {
const ref = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateScrollState = useEffectEvent(() => {
const element = ref.current;
if (!element) {
return;
}
setCanScrollLeft(element.scrollLeft > 0);
setCanScrollRight(
element.scrollLeft + element.clientWidth < element.scrollWidth - 1,
);
});
useEffect(() => {
const el = ref.current;
if (!el) return;
const element = ref.current;
if (!element) {
return;
}
const update = () => {
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
updateScrollState();
element.addEventListener("scroll", updateScrollState, { passive: true });
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
const resizeObserver = new ResizeObserver(updateScrollState);
resizeObserver.observe(element);
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
element.removeEventListener("scroll", updateScrollState);
resizeObserver.disconnect();
};
}, []);
useEffect(() => {
updateScrollState();
});
const scrollLeft = () => {
ref.current?.scrollBy({
left: -TAB_SCROLL_AMOUNT,
@@ -104,6 +120,37 @@ function useTabScroll() {
return { ref, canScrollLeft, canScrollRight, scrollLeft, scrollRight };
}
interface ScrollChevronButtonProps {
direction: "left" | "right";
onClick: () => void;
ariaLabel: string;
}
const ScrollChevronButton: FC<ScrollChevronButtonProps> = ({
direction,
onClick,
ariaLabel,
}) => {
const isLeft = direction === "left";
const Icon = isLeft ? ChevronLeftIcon : ChevronRightIcon;
return (
<button
type="button"
onClick={onClick}
aria-label={ariaLabel}
className={cn(
"absolute inset-y-0 z-10 flex w-8 cursor-pointer items-center border-none p-0 text-content-primary",
isLeft
? "left-0 justify-start pl-1 [background:linear-gradient(to_right,hsl(var(--surface-primary))_50%,transparent)]"
: "right-0 justify-end pr-1 [background:linear-gradient(to_left,hsl(var(--surface-primary))_50%,transparent)]",
)}
>
<Icon className="size-3.5" />
</button>
);
};
export const SidebarTabView: FC<SidebarTabViewProps> = ({
tabs,
isExpanded,
@@ -115,8 +162,16 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
desktopChatId,
effectiveTabId,
onActiveTabChange,
addTabControl,
}) => {
const tabIdPrefix = useId();
const {
ref: tabScrollRef,
canScrollLeft,
canScrollRight,
scrollLeft: scrollTabsLeft,
scrollRight: scrollTabsRight,
} = useTabScroll();
const allPanels: { id: string; content: ReactNode }[] = tabs.map((t) => ({
id: t.id,
@@ -134,14 +189,6 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
});
}
const {
ref: tabScrollRef,
canScrollLeft,
canScrollRight,
scrollLeft: scrollTabsLeft,
scrollRight: scrollTabsRight,
} = useTabScroll();
if (tabs.length === 0 && !desktopChatId) {
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-surface-primary">
@@ -167,6 +214,7 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
</span>
)}
</div>
{addTabControl}
<Button
variant="subtle"
size="icon"
@@ -214,24 +262,22 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
)}
<div className="relative min-w-0 flex-1">
{canScrollLeft && (
<button
type="button"
<ScrollChevronButton
ariaLabel="Scroll tabs left"
direction="left"
onClick={scrollTabsLeft}
aria-label="Scroll tabs left"
className="absolute left-0 top-0 z-10 flex h-full w-8 cursor-pointer items-center justify-start border-none p-0 pl-1 text-content-primary [background:linear-gradient(to_right,hsl(var(--surface-primary))_50%,transparent)]"
>
<ChevronLeftIcon className="size-3.5" />
</button>
/>
)}
<div
ref={tabScrollRef}
className="flex w-full items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
className="flex w-full min-w-0 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{tabs.map((tab) => {
const isActive = effectiveTabId === tab.id;
return (
const onClose = tab.onClose;
const isCloseable = onClose !== undefined;
const tabButton = (
<Button
key={tab.id}
id={`${tabIdPrefix}-tab-${tab.id}`}
role="tab"
aria-selected={isActive}
@@ -243,6 +289,7 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
isActive &&
"bg-surface-quaternary/25 text-content-primary hover:bg-surface-quaternary/50",
tab.badge && "pr-0",
isCloseable && "rounded-r-none border-r-0 pr-2.5",
)}
>
{tab.icon}
@@ -259,6 +306,36 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
)}
</Button>
);
if (!isCloseable) {
return (
<div key={tab.id} className="flex shrink-0 items-center">
{tabButton}
</div>
);
}
return (
<div key={tab.id} className="flex shrink-0 items-center">
{tabButton}
<Button
variant="outline"
size="icon"
onClick={(event) => {
event.stopPropagation();
onClose();
}}
aria-label={`Close ${tab.label} tab`}
className={cn(
"h-6 w-6 rounded-l-none rounded-r-md bg-surface-primary p-0 text-content-secondary hover:text-content-primary [&>svg]:size-3",
isActive &&
"bg-surface-quaternary/25 text-content-primary hover:bg-surface-quaternary/50",
)}
>
<XIcon />
</Button>
</div>
);
})}
{desktopChatId && (
<Button
@@ -277,16 +354,14 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
Desktop
</Button>
)}
{addTabControl}
</div>
{canScrollRight && (
<button
type="button"
<ScrollChevronButton
ariaLabel="Scroll tabs right"
direction="right"
onClick={scrollTabsRight}
aria-label="Scroll tabs right"
className="absolute right-0 top-0 z-10 flex h-full w-8 cursor-pointer items-center justify-end border-none p-0 pr-1 text-content-primary [background:linear-gradient(to_left,hsl(var(--surface-primary))_50%,transparent)]"
>
<ChevronRightIcon className="size-3.5" />
</button>
/>
)}
</div>
{isExpanded && chatTitle && (
@@ -301,25 +376,32 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
size="icon"
onClick={onToggleExpanded}
aria-label={isExpanded ? "Collapse panel" : "Expand panel"}
className="hidden size-7 shrink-0 text-content-secondary hover:text-content-primary lg:inline-flex"
className="hidden size-7 shrink-0 self-start text-content-secondary hover:text-content-primary lg:inline-flex"
>
{isExpanded ? <MinimizeIcon /> : <MaximizeIcon />}
</Button>
</div>
{allPanels.map((panel) => {
const isActive = effectiveTabId === panel.id;
return (
<div
key={panel.id}
role="tabpanel"
aria-labelledby={`${tabIdPrefix}-tab-${panel.id}`}
className={cn("min-h-0 flex-1", !isActive && "hidden")}
inert={!isActive}
>
{panel.content}
</div>
);
})}
<div className="relative flex min-h-0 flex-1 flex-col">
{allPanels.map((panel) => {
const isActive = effectiveTabId === panel.id;
return (
<div
key={panel.id}
role="tabpanel"
aria-labelledby={`${tabIdPrefix}-tab-${panel.id}`}
className={cn(
"min-h-0 flex-1",
// Keep inactive panels in the tree but invisible: a canvas xterm
// preserves painted pixels while hidden, so switching back is instant.
!isActive && "invisible absolute inset-0",
)}
inert={!isActive}
>
{panel.content}
</div>
);
})}
</div>
</div>
);
};
@@ -32,6 +32,7 @@ const meta = {
component: TerminalPanel,
args: {
chatId: "b5a8832c-72db-4679-8393-9a48dff20a20",
isHot: true,
workspaceAgent: createAgent("ready"),
},
parameters: {
@@ -1,4 +1,4 @@
import { type FC, useRef, useState } from "react";
import { type FC, useEffect, useEffectEvent, useRef, useState } from "react";
import { useQuery } from "react-query";
import { deploymentConfig } from "#/api/queries/deployment";
import { appearanceSettings } from "#/api/queries/users";
@@ -15,26 +15,89 @@ import {
import { WorkspaceTerminalAlerts } from "#/modules/terminal/WorkspaceTerminalAlerts";
import { openMaybePortForwardedURL } from "#/utils/portForward";
/** Promote a freshly created terminal tab after this delay if no output has painted. */
const READY_FALLBACK_MS = 100;
/** Keeps a recently hidden terminal attached long enough for quick tab toggles. */
const TERMINAL_IDLE_DETACH_MS = 30_000;
interface TerminalPanelProps {
/** Used as the reconnection token so the PTY session survives
* navigation and page reloads. */
chatId: string;
isVisible?: boolean;
reconnectionToken?: string;
/** Whether this terminal should hold live xterm and WebSocket resources. */
isHot?: boolean;
/**
* Gate on active-tab status, not just connect, so a tab connecting off screen
* does not steal focus from the user.
*/
autoFocus?: boolean;
/**
* Fires once the terminal is ready to be shown: the first output has
* painted, the connection dropped, or a brief fallback timeout elapsed.
*/
onReady?: () => void;
workspace?: TypesGen.Workspace;
workspaceAgent?: TypesGen.WorkspaceAgent;
}
export const TerminalPanel: FC<TerminalPanelProps> = ({
chatId,
isVisible,
reconnectionToken = chatId,
isHot,
autoFocus = true,
onReady,
workspace,
workspaceAgent,
}) => {
const { proxy } = useProxy();
const { metadata } = useEmbeddedMetadata();
const terminalRef = useRef<WorkspaceTerminalHandle>(null);
const [isWarm, setIsWarm] = useState(Boolean(isHot));
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("initializing");
const detachTerminal = useEffectEvent(() => {
setIsWarm(false);
setConnectionStatus("initializing");
});
useEffect(() => {
if (isHot) {
setIsWarm(true);
return;
}
if (!isWarm) {
return;
}
const timer = setTimeout(detachTerminal, TERMINAL_IDLE_DETACH_MS);
return () => clearTimeout(timer);
}, [isHot, isWarm]);
const shouldMountTerminal = Boolean(isHot) || isWarm;
const hasSignaledReadyRef = useRef(false);
const signalReady = useEffectEvent(() => {
if (hasSignaledReadyRef.current) {
return;
}
hasSignaledReadyRef.current = true;
onReady?.();
});
const handleStatusChange = (status: ConnectionStatus) => {
setConnectionStatus(status);
// A dropped connection produces no output, so signal readiness to surface
// the terminal alerts instead of waiting on the fallback timer.
if (status === "disconnected") {
signalReady();
}
};
useEffect(() => {
if (!shouldMountTerminal) {
return;
}
const timer = setTimeout(signalReady, READY_FALLBACK_MS);
return () => clearTimeout(timer);
}, [shouldMountTerminal]);
const config = useQuery(deploymentConfig());
const appearanceSettingsQuery = useQuery(
appearanceSettings(metadata.userAppearance),
@@ -49,8 +112,8 @@ export const TerminalPanel: FC<TerminalPanelProps> = ({
workspaceUsage({
usageApp: "reconnecting-pty",
connectionStatus,
workspaceId: workspace?.id,
agentId: workspaceAgent?.id,
workspaceId: shouldMountTerminal ? workspace?.id : undefined,
agentId: shouldMountTerminal ? workspaceAgent?.id : undefined,
}),
);
@@ -90,21 +153,25 @@ export const TerminalPanel: FC<TerminalPanelProps> = ({
onAlertChange={handleAlertChange}
/>
<div className="min-h-0 flex-1">
<WorkspaceTerminal
ref={terminalRef}
agentId={workspaceAgent.id}
operatingSystem={workspaceAgent.operating_system}
isVisible={isVisible}
onStatusChange={setConnectionStatus}
onError={handleTerminalError}
reconnectionToken={chatId}
baseUrl={terminalConfig.baseUrl}
terminalFontFamily={terminalConfig.fontFamily}
renderer={terminalConfig.renderer}
onOpenLink={handleOpenLink}
loading={config.isLoading || appearanceSettingsQuery.isLoading}
testId="agents-sidebar-terminal"
/>
{shouldMountTerminal && (
<WorkspaceTerminal
ref={terminalRef}
agentId={workspaceAgent.id}
operatingSystem={workspaceAgent.operating_system}
isVisible={shouldMountTerminal}
autoFocus={Boolean(isHot) && autoFocus}
onStatusChange={handleStatusChange}
onContentReady={signalReady}
onError={handleTerminalError}
reconnectionToken={reconnectionToken}
baseUrl={terminalConfig.baseUrl}
terminalFontFamily={terminalConfig.fontFamily}
renderer={terminalConfig.renderer}
onOpenLink={handleOpenLink}
loading={config.isLoading || appearanceSettingsQuery.isLoading}
testId="agents-sidebar-terminal"
/>
)}
</div>
</div>
);
@@ -0,0 +1,81 @@
import { isUserRightPanelTab, type UserRightPanelTab } from "./rightPanelTabs";
export const rightPanelTabStorageKeyPrefix = "agents.right-panel-tabs.";
export function getPersistedRightPanelTabs(
chatID: string | undefined,
): UserRightPanelTab[] {
if (!chatID) {
return [];
}
const value = localStorage.getItem(
`${rightPanelTabStorageKeyPrefix}${chatID}`,
);
if (!value) {
return [];
}
try {
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter(isUserRightPanelTab);
} catch {
return [];
}
}
export function savePersistedRightPanelTabs(
chatID: string | undefined,
tabs: readonly UserRightPanelTab[],
): void {
if (!chatID) {
return;
}
localStorage.setItem(
`${rightPanelTabStorageKeyPrefix}${chatID}`,
JSON.stringify(tabs),
);
}
const defaultTerminalHiddenStorageKeyPrefix = "agents.default-terminal-hidden.";
export function getPersistedDefaultTerminalHidden(
chatID: string | undefined,
): boolean {
if (!chatID) {
return false;
}
return (
localStorage.getItem(
`${defaultTerminalHiddenStorageKeyPrefix}${chatID}`,
) === "true"
);
}
export function savePersistedDefaultTerminalHidden(
chatID: string | undefined,
hidden: boolean,
): void {
if (!chatID) {
return;
}
const key = `${defaultTerminalHiddenStorageKeyPrefix}${chatID}`;
if (hidden) {
localStorage.setItem(key, "true");
} else {
localStorage.removeItem(key);
}
}
export function clearPersistedRightPanelState(
chatID: string | undefined,
): void {
if (!chatID) {
return;
}
localStorage.removeItem(`${rightPanelTabStorageKeyPrefix}${chatID}`);
localStorage.removeItem(`${defaultTerminalHiddenStorageKeyPrefix}${chatID}`);
}
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
clearPersistedRightPanelState,
getPersistedDefaultTerminalHidden,
getPersistedRightPanelTabs,
rightPanelTabStorageKeyPrefix,
savePersistedDefaultTerminalHidden,
savePersistedRightPanelTabs,
} from "./rightPanelTabStorage";
import type { UserRightPanelTab } from "./rightPanelTabs";
const terminalTab = (
overrides: Partial<UserRightPanelTab> = {},
): UserRightPanelTab => ({
id: "terminal-2",
kind: "terminal",
reconnectionToken: "11111111-1111-4111-8111-111111111111",
...overrides,
});
describe("right-panel tab storage", () => {
beforeEach(() => {
localStorage.clear();
});
it("persists tabs per chat", () => {
const tabs: UserRightPanelTab[] = [terminalTab()];
savePersistedRightPanelTabs("chat-1", tabs);
expect(getPersistedRightPanelTabs("chat-1")).toEqual(tabs);
expect(getPersistedRightPanelTabs("chat-2")).toEqual([]);
});
it("clears all persisted right-panel state for a chat", () => {
const tabs: UserRightPanelTab[] = [terminalTab()];
savePersistedRightPanelTabs("chat-1", tabs);
savePersistedDefaultTerminalHidden("chat-1", true);
savePersistedRightPanelTabs("chat-2", tabs);
savePersistedDefaultTerminalHidden("chat-2", true);
clearPersistedRightPanelState("chat-1");
expect(getPersistedRightPanelTabs("chat-1")).toEqual([]);
expect(getPersistedDefaultTerminalHidden("chat-1")).toBe(false);
expect(getPersistedRightPanelTabs("chat-2")).toEqual(tabs);
expect(getPersistedDefaultTerminalHidden("chat-2")).toBe(true);
});
it("ignores invalid stored values", () => {
localStorage.setItem(
`${rightPanelTabStorageKeyPrefix}chat-1`,
JSON.stringify([{ id: "bad-tab", kind: "terminal" }]),
);
expect(getPersistedRightPanelTabs("chat-1")).toEqual([]);
});
it("restores stored terminal tabs with string reconnect tokens", () => {
const tabs = [terminalTab({ reconnectionToken: "opaque-token" })];
localStorage.setItem(
`${rightPanelTabStorageKeyPrefix}chat-1`,
JSON.stringify(tabs),
);
expect(getPersistedRightPanelTabs("chat-1")).toEqual(tabs);
});
});
describe("default terminal hidden storage", () => {
beforeEach(() => {
localStorage.clear();
});
it("round trips a hidden terminal flag", () => {
savePersistedDefaultTerminalHidden("chat-1", true);
expect(getPersistedDefaultTerminalHidden("chat-1")).toBe(true);
expect(getPersistedDefaultTerminalHidden("chat-2")).toBe(false);
});
it("removes the stored flag when saving false", () => {
savePersistedDefaultTerminalHidden("chat-1", true);
savePersistedDefaultTerminalHidden("chat-1", false);
expect(getPersistedDefaultTerminalHidden("chat-1")).toBe(false);
expect(localStorage.length).toBe(0);
});
it("ignores undefined chat IDs", () => {
savePersistedDefaultTerminalHidden(undefined, true);
expect(getPersistedDefaultTerminalHidden(undefined)).toBe(false);
expect(localStorage.length).toBe(0);
});
it("treats malformed values as visible", () => {
savePersistedDefaultTerminalHidden("chat-1", true);
const key = localStorage.key(0);
if (!key) {
throw new Error("expected default terminal hidden key to be stored");
}
localStorage.setItem(key, "yes");
expect(getPersistedDefaultTerminalHidden("chat-1")).toBe(false);
});
});
@@ -0,0 +1,23 @@
export type UserRightPanelTab = {
id: string;
kind: "terminal";
reconnectionToken: string;
};
export function isUserRightPanelTab(
value: unknown,
): value is UserRightPanelTab {
if (typeof value !== "object" || value === null) {
return false;
}
const record = value as Record<string, unknown>;
if (typeof record.id !== "string") {
return false;
}
if (record.kind === "terminal") {
return typeof record.reconnectionToken === "string";
}
return false;
}