mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user