diff --git a/site/src/modules/terminal/WorkspaceTerminal.tsx b/site/src/modules/terminal/WorkspaceTerminal.tsx index 3cad12fd46..7e4f824c19 100644 --- a/site/src/modules/terminal/WorkspaceTerminal.tsx +++ b/site/src/modules/terminal/WorkspaceTerminal.tsx @@ -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(); 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, diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 1b4d125a00..dac70da0ee 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1602,6 +1602,7 @@ const AgentChatPage: FC = () => { return ( void; +} + +const UserTerminalTabContent: FC = ({ + tab, + chatId, + workspace, + workspaceAgent, + activeTabId, + pendingTabId, + isPanelVisible, + onReady, +}) => { + const isActive = activeTabId === tab.id; + return ( + onReady(tab.id)} + workspace={workspace} + workspaceAgent={workspaceAgent} + /> + ); +}; + export const AgentChatPageView: FC = ({ agentId, sendShortcut, @@ -299,6 +344,12 @@ export const AgentChatPageView: FC = ({ const [sidebarTabId, setSidebarTabIdState] = useState(() => getPersistedSidebarTabId(agentId), ); + const [userRightPanelTabs, setUserRightPanelTabsState] = useState< + UserRightPanelTab[] + >(() => getPersistedRightPanelTabs(agentId)); + const [defaultTerminalHidden, setDefaultTerminalHiddenState] = + useState(() => getPersistedDefaultTerminalHidden(agentId)); + const [pendingTabId, setPendingTabId] = useState(null); const setSidebarTabId = (tabId: string) => { setSidebarTabIdState(tabId); @@ -307,8 +358,21 @@ export const AgentChatPageView: FC = ({ } }; + 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 = ({ // 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 = ({ 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 = ({ return workspace && workspaceAgent ? ( handleTerminalTabReady("terminal")} workspace={workspace} workspaceAgent={workspaceAgent} /> @@ -411,15 +536,64 @@ export const AgentChatPageView: FC = ({ isVisible={shouldShowSidebar && effectiveSidebarTabId === "debug"} /> ); - default: - return null; + default: { + const userTab = visibleUserTabs.find((tab) => tab.id === tabId); + return userTab && workspace && workspaceAgent ? ( + + ) : 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 = ({ > + + + } onClose={() => onSetShowSidebarPanel(false)} isExpanded={visualExpanded} onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)} diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index a04f7fa0ff..a42505bcae 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -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), diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/SidebarTabView.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/SidebarTabView.stories.tsx index 09ea2b70fa..c0ad3eb77d 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/SidebarTabView.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/SidebarTabView.stories.tsx @@ -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([ + 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 ( + ({ + ...tab, + onClose: tab.id.startsWith("terminal-") + ? () => handleCloseTab(tab.id) + : undefined, + }))} + effectiveTabId={activeTabId} + onActiveTabChange={setActiveTabId} + isExpanded={false} + onToggleExpanded={() => {}} + addTabControl={ + + } + /> + ); + }, + 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: ( + + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: "New terminal tab" }), + ).toBeDisabled(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/SidebarTabView.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/SidebarTabView.tsx index 4047606a02..346d8e4f77 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/SidebarTabView.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/SidebarTabView.tsx @@ -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(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 = ({ + direction, + onClick, + ariaLabel, +}) => { + const isLeft = direction === "left"; + const Icon = isLeft ? ChevronLeftIcon : ChevronRightIcon; + + return ( + + ); +}; + export const SidebarTabView: FC = ({ tabs, isExpanded, @@ -115,8 +162,16 @@ export const SidebarTabView: FC = ({ 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 = ({ }); } - const { - ref: tabScrollRef, - canScrollLeft, - canScrollRight, - scrollLeft: scrollTabsLeft, - scrollRight: scrollTabsRight, - } = useTabScroll(); - if (tabs.length === 0 && !desktopChatId) { return (
@@ -167,6 +214,7 @@ export const SidebarTabView: FC = ({ )}
+ {addTabControl} + /> )}
{tabs.map((tab) => { const isActive = effectiveTabId === tab.id; - return ( + const onClose = tab.onClose; + const isCloseable = onClose !== undefined; + const tabButton = ( ); + + if (!isCloseable) { + return ( +
+ {tabButton} +
+ ); + } + + return ( +
+ {tabButton} + +
+ ); })} {desktopChatId && ( )} + {addTabControl}
{canScrollRight && ( - + /> )} {isExpanded && chatTitle && ( @@ -301,25 +376,32 @@ export const SidebarTabView: FC = ({ 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 ? : } - {allPanels.map((panel) => { - const isActive = effectiveTabId === panel.id; - return ( -
- {panel.content} -
- ); - })} +
+ {allPanels.map((panel) => { + const isActive = effectiveTabId === panel.id; + return ( +
+ {panel.content} +
+ ); + })} +
); }; diff --git a/site/src/pages/AgentsPage/components/TerminalPanel.stories.tsx b/site/src/pages/AgentsPage/components/TerminalPanel.stories.tsx index c18d3ff01a..6263b125d0 100644 --- a/site/src/pages/AgentsPage/components/TerminalPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/TerminalPanel.stories.tsx @@ -32,6 +32,7 @@ const meta = { component: TerminalPanel, args: { chatId: "b5a8832c-72db-4679-8393-9a48dff20a20", + isHot: true, workspaceAgent: createAgent("ready"), }, parameters: { diff --git a/site/src/pages/AgentsPage/components/TerminalPanel.tsx b/site/src/pages/AgentsPage/components/TerminalPanel.tsx index f07423ebb5..1f52eeb50c 100644 --- a/site/src/pages/AgentsPage/components/TerminalPanel.tsx +++ b/site/src/pages/AgentsPage/components/TerminalPanel.tsx @@ -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 = ({ chatId, - isVisible, + reconnectionToken = chatId, + isHot, + autoFocus = true, + onReady, workspace, workspaceAgent, }) => { const { proxy } = useProxy(); const { metadata } = useEmbeddedMetadata(); const terminalRef = useRef(null); + const [isWarm, setIsWarm] = useState(Boolean(isHot)); const [connectionStatus, setConnectionStatus] = useState("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 = ({ 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 = ({ onAlertChange={handleAlertChange} />
- + {shouldMountTerminal && ( + + )}
); diff --git a/site/src/pages/AgentsPage/utils/rightPanelTabStorage.ts b/site/src/pages/AgentsPage/utils/rightPanelTabStorage.ts new file mode 100644 index 0000000000..8947da3436 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/rightPanelTabStorage.ts @@ -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}`); +} diff --git a/site/src/pages/AgentsPage/utils/rightPanelTabs.test.ts b/site/src/pages/AgentsPage/utils/rightPanelTabs.test.ts new file mode 100644 index 0000000000..49fe7828d0 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/rightPanelTabs.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/rightPanelTabs.ts b/site/src/pages/AgentsPage/utils/rightPanelTabs.ts new file mode 100644 index 0000000000..d3e82ff408 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/rightPanelTabs.ts @@ -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; + if (typeof record.id !== "string") { + return false; + } + + if (record.kind === "terminal") { + return typeof record.reconnectionToken === "string"; + } + + return false; +}