From 5dc4329555deed77e98d20f2d1dce2463195032f Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Fri, 2 Oct 2026 17:49:03 +0300 Subject: [PATCH] Keep local terminal tabs on their selected context --- cmd/desktop/main.go | 1 + docs/configuration.md | 25 ++- internal/k8s/client.go | 13 +- internal/k8s/context_registry_test.go | 8 +- internal/server/localterm.go | 29 ++- .../server/localterm_context_unix_test.go | 125 ++++++++++- packages/k8s-ui/README.md | 8 + .../src/components/dock/DockContext.tsx | 1 + .../components/dock/LocalTerminalTab.test.tsx | 42 ++++ .../src/components/dock/LocalTerminalTab.tsx | 26 ++- .../src/components/dock/TerminalTab.tsx | 6 +- web/src/App.tsx | 5 +- .../components/ConnectionErrorView.test.tsx | 2 + web/src/components/ConnectionErrorView.tsx | 3 +- web/src/components/dock/BottomDock.tsx | 2 +- web/src/components/dock/DockContext.tsx | 29 ++- .../dock/LocalTerminalContext.test.tsx | 210 ++++++++++++++++++ .../components/dock/LocalTerminalTab.test.tsx | 33 ++- web/src/components/dock/LocalTerminalTab.tsx | 74 +++--- 19 files changed, 573 insertions(+), 69 deletions(-) create mode 100644 web/src/components/dock/LocalTerminalContext.test.tsx diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go index ba8b6150ad..659b951c91 100644 --- a/cmd/desktop/main.go +++ b/cmd/desktop/main.go @@ -227,6 +227,7 @@ func main() { log.Printf("K8s init failed (will show in UI): %v", k8sInitErr) k8s.SetConnectionStatus(k8s.ConnectionStatus{ State: k8s.StateDisconnected, + Context: k8s.GetContextName(), Error: k8sInitErr.Error(), ErrorType: "config", }) diff --git a/docs/configuration.md b/docs/configuration.md index 7cf475de14..3e07dccf30 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -597,8 +597,14 @@ Switching clusters in Radar leaves existing local shells running. A terminal opened for another context shows **Different context** and a **New terminal** action in the existing toolbar. Hover over the notice to see both full context names. The action opens a shell for the context Radar is now showing. -Reconnecting starts a new shell using Radar's then-active context, and updates -the tab's label from the server's new session. +Each tab retains the full context selected when it was opened. Reconnecting +starts a new shell only while Radar has that context selected; otherwise it +asks you to switch back or open a new terminal for the current context. The +tab keeps its previous context label during reconnect attempts. +Reconnect and Retry are disabled while Radar is showing a different context. +When a context is selected, the toolbar says **Requested** before the first shell starts. The server checks +the requested context against the same snapshot it exports, so switching +contexts while a tab is opening cannot start that shell for a different context. Commands supplied by actions such as **Authenticate in terminal** are sent once per tab. Reconnecting does not repeat a command that was already sent. If the connection closes before it is sent, the command remains pending for the next @@ -606,7 +612,20 @@ connection. If Radar cannot create a temporary kubeconfig, the existing original/inherited kubeconfig fallback remains available and the tab says **Context not confirmed**. -That shell's Kubernetes target has not been established by Radar. +That shell's Kubernetes target has not been established by Radar. If the selected +context differs from the tab's requested context, its notice says **Requested +context differs**, and the tooltip keeps that request distinct from a confirmed +kubeconfig. +The Desktop app can also open an unconfirmed recovery shell when no kubeconfig +is available. Its explicit empty-context intent must match the server's empty +active context; once a context is selected, open a new terminal for it. +These checks preserve context selection across opens and reconnects; they do +not enforce a shell's live command target or detect a kubeconfig context being +repointed to a different physical cluster under the same name. +If a context switch fails before Radar changes its active client, a terminal +requested for the new context is refused. Finish recovering that connection, +switch back to the previous context, or copy the recovery command into an +external terminal. Radar will not substitute a shell for the previous context. ## Namespace Picker diff --git a/internal/k8s/client.go b/internal/k8s/client.go index ee21760d77..c8cf75b42d 100644 --- a/internal/k8s/client.go +++ b/internal/k8s/client.go @@ -964,7 +964,7 @@ func recordEmptyCommandWarning(source string, authInfos []string) { // current-context set to Radar's active context. The caller must remove the // file when done. Returns the temp file path. func WriteKubeconfigForCurrentContext() (string, error) { - snapshot, err := WriteKubeconfigSnapshotForCurrentContext() + snapshot, err := WriteKubeconfigSnapshotForCurrentContext(nil) return snapshot.Path, err } @@ -973,9 +973,12 @@ type KubeconfigSnapshot struct { Context string } +var ErrKubeconfigContextMismatch = errors.New("terminal context changed") + // WriteKubeconfigSnapshotForCurrentContext returns the temporary config and its -// display context from the same client-state snapshot. The caller owns the file. -func WriteKubeconfigSnapshotForCurrentContext() (KubeconfigSnapshot, error) { +// display context from the same client-state snapshot. A non-nil expectedContext +// must match exactly, including an empty context. The caller owns the file. +func WriteKubeconfigSnapshotForCurrentContext(expectedContext *string) (KubeconfigSnapshot, error) { clientMu.RLock() ctx := contextName activeFile := activeSourceFile @@ -986,6 +989,10 @@ func WriteKubeconfigSnapshotForCurrentContext() (KubeconfigSnapshot, error) { singlePath := kubeconfigPath clientMu.RUnlock() + if expectedContext != nil && *expectedContext != ctx { + return KubeconfigSnapshot{}, fmt.Errorf("%w: this terminal is for %q; Radar is showing %q", ErrKubeconfigContextMismatch, *expectedContext, ctx) + } + var rawConfig clientcmdapi.Config var currentContextForFile string diff --git a/internal/k8s/context_registry_test.go b/internal/k8s/context_registry_test.go index 9317e535e1..c80c4bda6d 100644 --- a/internal/k8s/context_registry_test.go +++ b/internal/k8s/context_registry_test.go @@ -652,11 +652,17 @@ func TestWriteKubeconfigSnapshotPreservesDisplayContext(t *testing.T) { contextName, activeSourceFile, activeSourceName, activeSourceConfig = oldName, oldFile, oldSourceName, oldConfig clientMu.Unlock() }) - snapshot, err := WriteKubeconfigSnapshotForCurrentContext() + expected := "production@secondary" + snapshot, err := WriteKubeconfigSnapshotForCurrentContext(&expected) if err != nil { t.Fatal(err) } defer os.Remove(snapshot.Path) + expected = "production" + rejected, err := WriteKubeconfigSnapshotForCurrentContext(&expected) + if !errors.Is(err, ErrKubeconfigContextMismatch) || rejected.Path != "" { + t.Fatalf("unqualified context accepted: snapshot=%+v error=%v", rejected, err) + } SetTestContextName("staging") written, err := clientcmd.LoadFromFile(snapshot.Path) if err != nil { diff --git a/internal/server/localterm.go b/internal/server/localterm.go index 35c4a9010b..b02a67e7a7 100644 --- a/internal/server/localterm.go +++ b/internal/server/localterm.go @@ -2,6 +2,7 @@ package server import ( "encoding/json" + "errors" "fmt" "io" "log" @@ -111,13 +112,33 @@ func (s *Server) localTerminalUnavailable(r *http.Request) (int, string) { return 0, "" } -// handleLocalTerminal handles WebSocket connections for local terminal sessions +// handleLocalTerminal opens a shell only when expectedContext matches the +// exported client-state snapshot. It never switches Radar's active context. func (s *Server) handleLocalTerminal(w http.ResponseWriter, r *http.Request) { if status, message := s.localTerminalUnavailable(r); status != 0 { s.writeError(w, status, message) return } + if !s.websocketOriginAllowed(r) { + s.writeError(w, http.StatusForbidden, "local terminal origin is not allowed") + return + } + + expectedContext := r.URL.Query().Get("expectedContext") + if !r.URL.Query().Has("expectedContext") { + s.writeError(w, http.StatusBadRequest, "Open a new terminal from Radar to select its context") + return + } + kubeconfig, configErr := k8s.WriteKubeconfigSnapshotForCurrentContext(&expectedContext) + if errors.Is(configErr, k8s.ErrKubeconfigContextMismatch) { + s.writeError(w, http.StatusConflict, configErr.Error()) + return + } + if kubeconfig.Path != "" { + defer os.Remove(kubeconfig.Path) + } + // Upgrade to WebSocket conn, err := s.upgradeWebSocket(w, r) if err != nil { @@ -136,11 +157,10 @@ func (s *Server) handleLocalTerminal(w http.ResponseWriter, r *http.Request) { // Set up environment: inherit current process env, override KUBECONFIG // with a temp copy that has current-context set to Radar's active context. env := os.Environ() - kubeconfig, err := k8s.WriteKubeconfigSnapshotForCurrentContext() tmpKubeconfig := kubeconfig.Path sessionInfo := localTermSessionInfo{Type: "session"} - if err != nil { - log.Printf("[localterm] Failed to write temp kubeconfig, falling back to default: %v", err) + if configErr != nil { + log.Printf("[localterm] Failed to write temp kubeconfig, falling back to default: %v", configErr) if kubeconfigPath := k8s.GetKubeconfigPath(); kubeconfigPath != "" { env = setEnv(env, "KUBECONFIG", kubeconfigPath) } @@ -148,7 +168,6 @@ func (s *Server) handleLocalTerminal(w http.ResponseWriter, r *http.Request) { env = setEnv(env, "KUBECONFIG", tmpKubeconfig) sessionInfo.Context = kubeconfig.Context sessionInfo.KubeconfigIsolated = true - defer os.Remove(tmpKubeconfig) } // Ensure TERM is set so the shell's terminfo binds the escape sequences diff --git a/internal/server/localterm_context_unix_test.go b/internal/server/localterm_context_unix_test.go index fbd7b5a455..3b54724af3 100644 --- a/internal/server/localterm_context_unix_test.go +++ b/internal/server/localterm_context_unix_test.go @@ -5,6 +5,7 @@ package server import ( "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" @@ -17,8 +18,16 @@ import ( ) func TestLocalTerminalReportsExportedContextBeforeOutput(t *testing.T) { - for _, isolated := range []bool{true, false} { - t.Run(map[bool]string{true: "isolated", false: "fallback"}[isolated], func(t *testing.T) { + for _, tc := range []struct { + name, context string + isolated bool + }{ + {"isolated", "production", true}, + {"fallback", "missing", false}, + {"no active context", "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + isolated := tc.isolated t.Cleanup(k8s.SetTestLocalMode()) previousDisabled := k8s.ForceDisableLocalTerminal k8s.ForceDisableLocalTerminal = false @@ -32,13 +41,14 @@ func TestLocalTerminalReportsExportedContextBeforeOutput(t *testing.T) { if isolated { t.Cleanup(k8s.SetTestProfileSource(filepath.Join(dir, "absent.yaml"), "production", "demo-user")) } else { - t.Cleanup(k8s.SetTestProfileSource("", "missing", "")) + t.Cleanup(k8s.SetTestProfileSource("", tc.context, "")) t.Cleanup(k8s.SetTestRegistryEntry("other", filepath.Join(dir, "absent.yaml"), "other")) } server := &Server{listenAddress: DefaultListenAddress} httpServer := httptest.NewServer(http.HandlerFunc(server.handleLocalTerminal)) defer httpServer.Close() - conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(httpServer.URL, "http")+"/api/local-terminal", nil) + expected := tc.context + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(httpServer.URL, "http")+"/api/local-terminal?expectedContext="+url.QueryEscape(expected), nil) if err != nil { t.Fatal(err) } @@ -65,3 +75,110 @@ func TestLocalTerminalReportsExportedContextBeforeOutput(t *testing.T) { }) } } + +func TestLocalTerminalContextGuardBeforeShell(t *testing.T) { + t.Cleanup(k8s.SetTestLocalMode()) + previousDisabled := k8s.ForceDisableLocalTerminal + k8s.ForceDisableLocalTerminal = false + t.Cleanup(func() { k8s.ForceDisableLocalTerminal = previousDisabled }) + dir := t.TempDir() + t.Cleanup(k8s.SetTestProfileSource(filepath.Join(dir, "absent.yaml"), "production@secondary", "demo-user")) + t.Setenv("TMPDIR", dir) + marker := filepath.Join(dir, "shell-started") + shell := filepath.Join(dir, "fixture-shell") + if err := os.WriteFile(shell, []byte("#!/bin/sh\ntouch \"$GUARD_SHELL_MARKER\"\nprintf 'fixture output\\n'\ncat\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("SHELL", shell) + t.Setenv("GUARD_SHELL_MARKER", marker) + server := &Server{listenAddress: DefaultListenAddress} + httpServer := httptest.NewServer(http.HandlerFunc(server.handleLocalTerminal)) + defer httpServer.Close() + + for _, tc := range []struct { + name, expected, origin string + status int + }{ + {"missing", "", "", http.StatusBadRequest}, + {"empty intent with active context", "", "", http.StatusConflict}, + {"different context", "staging", "", http.StatusConflict}, + {"same short name", "production@primary", "", http.StatusConflict}, + {"cross origin", "production@secondary", "http://attacker.example", http.StatusForbidden}, + } { + t.Run(tc.name, func(t *testing.T) { + headers := http.Header{} + if tc.origin != "" { + headers.Set("Origin", tc.origin) + } + requestURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/api/local-terminal" + if tc.name != "missing" { + requestURL += "?expectedContext=" + url.QueryEscape(tc.expected) + } + conn, resp, err := websocket.DefaultDialer.Dial(requestURL, headers) + if conn != nil { + conn.Close() + t.Fatal("rejected context opened a WebSocket") + } + if err == nil || resp == nil || resp.StatusCode != tc.status { + t.Fatalf("handshake: response=%v error=%v, want %d", resp, err, tc.status) + } + resp.Body.Close() + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("rejected request started the shell: %v", err) + } + }) + } + + t.Run("matching plain request cleans exported kubeconfig", func(t *testing.T) { + resp, err := http.Get(httpServer.URL + "/api/local-terminal?expectedContext=production%40secondary") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("plain GET status = %d", resp.StatusCode) + } + files, err := filepath.Glob(filepath.Join(dir, "radar-kubeconfig-*.yaml")) + if err != nil || len(files) != 0 { + t.Fatalf("kubeconfigs after failed upgrade = %v, error = %v", files, err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("failed upgrade started a shell: %v", err) + } + }) + + oldStatus := k8s.GetConnectionStatus() + k8s.SetConnectionStatus(k8s.ConnectionStatus{State: k8s.StateDisconnected, Context: "staging", ErrorType: "auth"}) + t.Cleanup(func() { k8s.SetConnectionStatus(oldStatus) }) + conn, resp, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(httpServer.URL, "http")+"/api/local-terminal?expectedContext=staging", nil) + if conn != nil { + conn.Close() + t.Fatal("failed switch silently opened the previous active context") + } + if err == nil || resp == nil || resp.StatusCode != http.StatusConflict { + t.Fatalf("failed switch: response=%v error=%v", resp, err) + } + resp.Body.Close() + + k8s.SetConnectionStatus(k8s.ConnectionStatus{State: k8s.StateDisconnected, Context: k8s.GetContextName(), ErrorType: "config"}) + conn, _, err = websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(httpServer.URL, "http")+"/api/local-terminal?expectedContext=production%40secondary", nil) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + var info localTermSessionInfo + if err := conn.ReadJSON(&info); err != nil { + t.Fatal(err) + } + if info.Context != "production@secondary" || !info.KubeconfigIsolated { + t.Fatalf("matching disconnected context did not open with cached config: %+v", info) + } + var output TerminalMessage + if err := conn.ReadJSON(&output); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("accepted request did not start the shell: %v", err) + } +} diff --git a/packages/k8s-ui/README.md b/packages/k8s-ui/README.md index 207dc89f4e..312a353f94 100644 --- a/packages/k8s-ui/README.md +++ b/packages/k8s-ui/README.md @@ -11,6 +11,14 @@ This describes the supplied kubeconfig, not a shell's live command target. `toolbarExtra` lets the host render that information in the terminal toolbar. Hosts can update a dock tab's label and optional full-name tooltip with `useDock().setTabTitle(id, title, titleTooltip)`. +The optional `DockTab.localTerminalContext` field lets a host retain the full +context selected when a local terminal was requested. An empty string records +no active context; an omitted field records no intent. The shared terminal and +open hook do not interpret it; Radar's host wrapper checks it on open/reconnect. +`canConnect` is a predicate evaluated during rendering to disable Reconnect/Retry, +and before an attempt to preserve the existing terminal when refused. +`onConnectionError` lets a host refresh connection state after a failed WebSocket +handshake. `initialCommand` is sent once per mounted terminal. Reconnect does not repeat a command that was already sent; if the connection closes before delivery, the diff --git a/packages/k8s-ui/src/components/dock/DockContext.tsx b/packages/k8s-ui/src/components/dock/DockContext.tsx index f08b7a20bf..02ee2083f6 100644 --- a/packages/k8s-ui/src/components/dock/DockContext.tsx +++ b/packages/k8s-ui/src/components/dock/DockContext.tsx @@ -23,6 +23,7 @@ export interface DockTab { nodeName?: string // Local terminal props initialCommand?: string + localTerminalContext?: string } export interface DockContextValue { diff --git a/packages/k8s-ui/src/components/dock/LocalTerminalTab.test.tsx b/packages/k8s-ui/src/components/dock/LocalTerminalTab.test.tsx index 7c3aae6bc7..fce73d36db 100644 --- a/packages/k8s-ui/src/components/dock/LocalTerminalTab.test.tsx +++ b/packages/k8s-ui/src/components/dock/LocalTerminalTab.test.tsx @@ -3,6 +3,7 @@ import { act, StrictMode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { LocalTerminalTab, type LocalTerminalTabProps } from './LocalTerminalTab' +import { TerminalTab } from './TerminalTab' vi.mock('@xterm/xterm', () => ({ Terminal: class { @@ -145,3 +146,44 @@ it('sends the initial command independently in each terminal tab', async () => { expect(socket.send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toEqual([[input]]) } }) + +it('keeps a blocked reconnect intact until the host permits it', async () => { + vi.useFakeTimers() + let allowed = true + const createSession = vi.fn(async () => ({ wsUrl: 'ws://localhost' })) + const onSessionInfo = vi.fn() + await render({ createSession, canConnect: () => allowed, initialCommand: 'auth-command', onSessionInfo }) + await act(async () => Socket.instances[0].onopen?.()) + await act(async () => vi.advanceTimersByTime(300)) + await act(async () => Socket.instances[0].close()) + const terminal = element.querySelector('div.absolute') + expect(terminal).not.toBeNull() + const metadataCalls = onSessionInfo.mock.calls.length + allowed = false + await act(async () => element.querySelector('button')!.click()) + expect(createSession).toHaveBeenCalledTimes(1) + expect(onSessionInfo).toHaveBeenCalledTimes(metadataCalls) + expect(element.contains(terminal)).toBe(true) + allowed = true + await act(async () => element.querySelector('button')!.click()) + await act(async () => Socket.instances[1].onopen?.()) + await act(async () => vi.advanceTimersByTime(300)) + expect(createSession).toHaveBeenCalledTimes(2) + expect(Socket.instances.flatMap(s => s.send.mock.calls).filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1) +}) + +it.each(['local', 'pod'])('allows Retry after a %s terminal creation error', async kind => { + const createSession = vi.fn() + .mockRejectedValueOnce(new Error('creation failed')) + .mockResolvedValueOnce({ wsUrl: 'ws://localhost' }) + await act(async () => root.render(kind === 'local' + ? + : )) + expect(element.textContent).toContain('creation failed') + const retry = [...element.querySelectorAll('button')].find(button => button.textContent?.trim() === 'Retry')! + await act(async () => retry.click()) + expect(createSession).toHaveBeenCalledTimes(2) + expect(Socket.instances).toHaveLength(1) + await act(async () => Socket.instances[0].onopen?.()) + expect(element.textContent).not.toContain('creation failed') +}) diff --git a/packages/k8s-ui/src/components/dock/LocalTerminalTab.tsx b/packages/k8s-ui/src/components/dock/LocalTerminalTab.tsx index e7c62b5afb..5d7bbf7151 100644 --- a/packages/k8s-ui/src/components/dock/LocalTerminalTab.tsx +++ b/packages/k8s-ui/src/components/dock/LocalTerminalTab.tsx @@ -20,6 +20,8 @@ export interface LocalTerminalTabProps { createSession: () => Promise<{ wsUrl: string }> /** Command to send once per mounted terminal after a connection opens */ initialCommand?: string + canConnect?: () => boolean + onConnectionError?: () => void onSessionInfo?: (info: LocalTerminalSessionInfo | null) => void toolbarExtra?: ReactNode } @@ -28,6 +30,8 @@ export function LocalTerminalTab({ isActive = true, createSession, initialCommand, + canConnect, + onConnectionError, onSessionInfo, toolbarExtra, }: LocalTerminalTabProps) { @@ -40,12 +44,17 @@ export function LocalTerminalTab({ const initialCommandSentRef = useRef(false) const createSessionRef = useRef(createSession) const onSessionInfoRef = useRef(onSessionInfo) + const canConnectRef = useRef(canConnect) + const onConnectionErrorRef = useRef(onConnectionError) useLayoutEffect(() => { createSessionRef.current = createSession }, [createSession]) useLayoutEffect(() => { onSessionInfoRef.current = onSessionInfo }, [onSessionInfo]) + useLayoutEffect(() => { canConnectRef.current = canConnect }, [canConnect]) + useLayoutEffect(() => { onConnectionErrorRef.current = onConnectionError }, [onConnectionError]) const [isConnected, setIsConnected] = useState(false) const [isConnecting, setIsConnecting] = useState(true) const [error, setError] = useState(null) const [hasSelection, setHasSelection] = useState(false) + const connectAllowed = canConnect?.() !== false const { confirmPaste, pasteDialog } = useMultilinePasteConfirm() const handleCopy = useCallback(() => { @@ -55,6 +64,11 @@ export function LocalTerminalTab({ const connect = useCallback(() => { if (!terminalRef.current) return + if (canConnectRef.current && !canConnectRef.current()) { + setIsConnecting(false) + return + } + const attempt = ++connectionAttemptRef.current onSessionInfoRef.current?.(null) setIsConnecting(true) @@ -202,6 +216,7 @@ export function LocalTerminalTab({ ws.onerror = () => { if (attempt !== connectionAttemptRef.current) return + onConnectionErrorRef.current?.() setError((prev) => prev || 'Connection error') setIsConnected(false) setIsConnecting(false) @@ -263,7 +278,8 @@ export function LocalTerminalTab({ {!isConnected && !isConnecting && ( - ) : ( -
)} {pasteDialog}
diff --git a/packages/k8s-ui/src/components/dock/TerminalTab.tsx b/packages/k8s-ui/src/components/dock/TerminalTab.tsx index ad53c6f766..68cedf5abf 100644 --- a/packages/k8s-ui/src/components/dock/TerminalTab.tsx +++ b/packages/k8s-ui/src/components/dock/TerminalTab.tsx @@ -298,8 +298,8 @@ export function TerminalTab({ {!error && } - {/* Terminal or error — key forces xterm canvas unmount/remount on toggle */} - {error ? ( +
+ {error && (
{errorType === 'shell_not_found' ? ( <> @@ -347,8 +347,6 @@ export function TerminalTab({ )}
- ) : ( -
)} {pasteDialog}
diff --git a/web/src/App.tsx b/web/src/App.tsx index cc0759e1e5..18f08b28ff 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1890,11 +1890,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL {/* Local terminal */} {capabilities.localTerminal && ( - + diff --git a/web/src/components/ConnectionErrorView.test.tsx b/web/src/components/ConnectionErrorView.test.tsx index b76e98d54a..c3521078b8 100644 --- a/web/src/components/ConnectionErrorView.test.tsx +++ b/web/src/components/ConnectionErrorView.test.tsx @@ -12,6 +12,8 @@ const capabilitiesMock = vi.hoisted(() => ({ localTerminal: true })) vi.mock('@skyhook-io/k8s-ui', () => ({ ClusterName: ({ name }: { name: string }) => {name}, +})) +vi.mock('./dock/DockContext', () => ({ useOpenLocalTerminal: () => vi.fn(), })) vi.mock('../api/client', () => ({ diff --git a/web/src/components/ConnectionErrorView.tsx b/web/src/components/ConnectionErrorView.tsx index c35e876718..1de714bfcf 100644 --- a/web/src/components/ConnectionErrorView.tsx +++ b/web/src/components/ConnectionErrorView.tsx @@ -3,7 +3,8 @@ import { useEffect, useState } from 'react' import type { ConnectionState } from '../context/ConnectionContext' import { ContextSwitcher } from './ContextSwitcher' import { parseContextName } from '../utils/context-name' -import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui' +import { ClusterName } from '@skyhook-io/k8s-ui' +import { useOpenLocalTerminal } from './dock/DockContext' import { useAuthMe, useContexts } from '../api/client' import { Tooltip } from './ui/Tooltip' import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse' diff --git a/web/src/components/dock/BottomDock.tsx b/web/src/components/dock/BottomDock.tsx index ae18ee6a80..23a2798797 100644 --- a/web/src/components/dock/BottomDock.tsx +++ b/web/src/components/dock/BottomDock.tsx @@ -53,7 +53,7 @@ function renderTabContent(tab: DockTab, isActive: boolean) { if (tab.type === 'local-terminal') { return ( - + ) } diff --git a/web/src/components/dock/DockContext.tsx b/web/src/components/dock/DockContext.tsx index d52d881077..83b307e3dd 100644 --- a/web/src/components/dock/DockContext.tsx +++ b/web/src/components/dock/DockContext.tsx @@ -1,4 +1,8 @@ -// Re-exported from @skyhook-io/k8s-ui — source of truth is radar/packages/k8s-ui/src/components/dock/DockContext.tsx +import { useCallback, useLayoutEffect, useRef } from 'react' +import { useDock } from '@skyhook-io/k8s-ui' +import { useConnection } from '../../context/ConnectionContext' +import { useToast } from '../ui/Toast' + export { DockProvider, useDock, @@ -7,6 +11,27 @@ export { useOpenLogs, useOpenWorkloadLogs, useOpenNodeTerminal, - useOpenLocalTerminal, } from '@skyhook-io/k8s-ui' export type { DockTab, DockTabType, DockContextValue } from '@skyhook-io/k8s-ui' + +export function useOpenLocalTerminal() { + const { addTab } = useDock() + const { connection } = useConnection() + const { showToast } = useToast() + const contextRef = useRef({ context: connection.context, state: connection.state }) + useLayoutEffect(() => { contextRef.current = { context: connection.context, state: connection.state } }, [connection.context, connection.state]) + + return useCallback((opts?: { initialCommand?: string; title?: string }) => { + const { context, state } = contextRef.current + if (!context && state !== 'disconnected') { + showToast('Waiting for a context. Try opening the terminal again.', { type: 'info' }) + return + } + addTab({ + type: 'local-terminal', + title: opts?.title || 'Terminal', + initialCommand: opts?.initialCommand, + localTerminalContext: context, + }) + }, [addTab, showToast]) +} diff --git a/web/src/components/dock/LocalTerminalContext.test.tsx b/web/src/components/dock/LocalTerminalContext.test.tsx new file mode 100644 index 0000000000..93080ff5cb --- /dev/null +++ b/web/src/components/dock/LocalTerminalContext.test.tsx @@ -0,0 +1,210 @@ +// @vitest-environment jsdom +import { act } from 'react' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import { createRoot, type Root } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { DockProvider, useDock, useOpenLocalTerminal as useSharedOpenLocalTerminal } from '../../../../packages/k8s-ui/src/components/dock/DockContext' +import { useOpenLocalTerminal } from './DockContext' +import { LocalTerminalTab } from './LocalTerminalTab' +import { ToastProvider } from '../../../../packages/k8s-ui/src/components/ui/Toast' + +const state = vi.hoisted(() => ({ context: 'A', status: 'connected', mountTabs: true, open: null as null | (() => void) })) +vi.mock('../../context/ConnectionContext', () => ({ + useConnection: () => ({ connection: { context: state.context, state: state.status } }), +})) +vi.mock('@skyhook-io/k8s-ui', async () => ({ + ...await import('../../../../packages/k8s-ui/src/components/dock/DockContext'), + ...await import('../../../../packages/k8s-ui/src/components/dock/LocalTerminalTab'), + ...await import('../../../../packages/k8s-ui/src/components/ui/ClusterName'), + ...await import('../../../../packages/k8s-ui/src/components/ui/Tooltip'), + ...await import('../../../../packages/k8s-ui/src/components/ui/Toast'), + ...await import('../../../../packages/k8s-ui/src/utils/context-name'), +})) +vi.mock('../../../../packages/k8s-ui/src/components/dock/terminalClipboard', () => ({ setupTerminalClipboard: () => () => {}, copyTerminalSelection: vi.fn() })) +vi.mock('../../../../packages/k8s-ui/src/components/dock/TerminalClipboardToolbar', () => ({ TerminalClipboardToolbar: () => null })) +vi.mock('../../../../packages/k8s-ui/src/components/dock/useMultilinePasteConfirm', () => ({ useMultilinePasteConfirm: () => ({ confirmPaste: vi.fn(), pasteDialog: null }) })) +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) +const { Terminal } = createRequire(resolve(__dirname, '../../../../packages/k8s-ui/package.json'))('@xterm/xterm') + +class Socket { + static OPEN = 1 + static instances: Socket[] = [] + readyState = 0 + onopen?: () => void + onclose?: () => void + onerror?: () => void + onmessage?: (event: { data: string }) => void + send = vi.fn() + constructor(public url: string) { Socket.instances.push(this) } + open() { this.readyState = 1; this.onopen?.() } + close() { this.readyState = 3; this.onclose?.() } + session(context: string) { + this.onmessage?.({ data: JSON.stringify({ type: 'session', context, kubeconfigIsolated: true }) }) + } +} +let root: Root +let element: HTMLDivElement +let client: QueryClient +function Harness() { + const { tabs } = useDock() + const open = useOpenLocalTerminal() + const sharedOpen = useSharedOpenLocalTerminal() + state.open ??= () => open({ title: 'Auth', initialCommand: 'auth-marker' }) + return <> + + + {state.mountTabs && tabs.map(tab =>
+

{tab.title}

+ +
)} + +} +async function render() { + await act(async () => root.render()) +} +async function click(text: string, section: ParentNode = element) { + await act(async () => [...section.querySelectorAll('button')].find(button => button.textContent?.trim() === text)!.click()) +} +beforeEach(() => { + vi.spyOn(Terminal.prototype, 'open').mockImplementation(() => {}) + state.context = 'A' + state.status = 'connected' + state.mountTabs = true + state.open = null + Socket.instances = [] + vi.useFakeTimers() + vi.stubGlobal('WebSocket', Socket) + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) + vi.stubGlobal('requestAnimationFrame', () => 0) + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + element = document.createElement('div') + document.body.appendChild(element) + root = createRoot(element) +}) +afterEach(async () => { + await act(async () => root.unmount()) + client.clear() + element.remove() + vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it('captures A at the action before mounting, and keeps first delivery pending while B is selected', async () => { + state.mountTabs = false + await render() + await click('Open') + state.context = 'B' + state.mountTabs = true + await render() + expect(Socket.instances).toHaveLength(0) + expect(element.textContent).toContain('Requested:A') + expect(element.querySelector('button[aria-label="New terminal for B"]')).not.toBeNull() + expect([...element.querySelectorAll('button')].find(b => b.textContent?.trim() === 'Reconnect')?.disabled).toBe(true) + expect(element.textContent).toContain('Different context') + state.context = 'A' + await render() + await click('Reconnect') + expect(new URL(Socket.instances[0].url).searchParams.get('expectedContext')).toBe('A') + await act(async () => { Socket.instances[0].open(); Socket.instances[0].session('A'); vi.advanceTimersByTime(300) }) + expect(Socket.instances[0].send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toEqual([[JSON.stringify({ type: 'input', data: 'auth-marker\n' })]]) +}) + +it('preserves A through a refused reconnect, creates B independently, and reconnects A without replay', async () => { + await render() + await click('Open') + await act(async () => { Socket.instances[0].open(); Socket.instances[0].session('A'); vi.advanceTimersByTime(300) }) + state.context = 'B' + await render() + expect(Socket.instances[0].readyState).toBe(1) + await act(async () => Socket.instances[0].close()) + await click('Reconnect') + expect(Socket.instances).toHaveLength(1) + expect(element.querySelector('h2')?.textContent).toBe('Auth · A') + await click('New terminal') + expect(new URL(Socket.instances[1].url).searchParams.get('expectedContext')).toBe('B') + await act(async () => { Socket.instances[1].open(); Socket.instances[1].session('B') }) + expect([...element.querySelectorAll('h2')].map(e => e.textContent)).toEqual(['Auth · A', 'Terminal · B']) + state.context = 'A' + await render() + await click('Reconnect', element.querySelector('section')!) + await act(async () => { Socket.instances[2].open(); Socket.instances[2].session('A'); vi.advanceTimersByTime(300) }) + expect(new URL(Socket.instances[2].url).searchParams.get('expectedContext')).toBe('A') + expect(Socket.instances.flatMap(s => s.send.mock.calls).filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1) +}) + +it('refreshes connection state after handshake failure and can retry without consuming the command', async () => { + const refresh = vi.spyOn(client, 'invalidateQueries') + await render() + await click('Open') + await act(async () => { Socket.instances[0].onerror?.(); Socket.instances[0].close() }) + expect(refresh).toHaveBeenCalledWith({ queryKey: ['connection-status'] }) + state.context = 'B' + await render() + expect([...element.querySelectorAll('button')].find(b => b.textContent?.trim() === 'Retry')?.disabled).toBe(true) + await click('Retry') + expect(Socket.instances).toHaveLength(1) + state.context = 'A' + await render() + await click('Retry') + await act(async () => { Socket.instances[1].open(); Socket.instances[1].session('A'); vi.advanceTimersByTime(300) }) + expect(Socket.instances[1].send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1) +}) + +it('explains unknown-context refusal and lets an earlier callback read the latest committed context', async () => { + state.context = '' + state.status = 'connecting' + await render() + await click('Open') + expect(element.querySelector('section')).toBeNull() + expect(element.textContent).toContain('Waiting for a context. Try opening the terminal again.') + expect(Socket.instances).toHaveLength(0) + state.context = 'A & B/qualified' + await render() + expect(Socket.instances).toHaveLength(0) + await act(async () => state.open!()) + expect(new URL(Socket.instances[0].url).searchParams.get('expectedContext')).toBe('A & B/qualified') +}) + +it('gives a shared-hook tab without an intent an explicit state and a fresh context-aware action', async () => { + await render() + await click('Open shared') + expect(element.textContent).toContain('No context selected') + expect(Socket.instances).toHaveLength(0) + expect([...element.querySelectorAll('button')].find(b => b.textContent?.trim() === 'Reconnect')?.disabled).toBe(true) + await click('New terminal') + expect(new URL(Socket.instances[0].url).searchParams.get('expectedContext')).toBe('A') +}) + +it('preserves the no-kubeconfig recovery shell with an explicit empty intent, then blocks reconnect under A', async () => { + state.context = '' + state.status = 'disconnected' + await render() + await click('Open') + const query = new URL(Socket.instances[0].url).searchParams + expect(query.has('expectedContext')).toBe(true) + expect(query.get('expectedContext')).toBe('') + await act(async () => { Socket.instances[0].open(); Socket.instances[0].onmessage?.({ data: JSON.stringify({ type: 'session', context: '', kubeconfigIsolated: false }) }); vi.advanceTimersByTime(300) }) + expect(element.textContent).toContain('Context not confirmed') + expect(Socket.instances[0].send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1) + state.context = 'A' + state.status = 'connected' + await render() + expect(Socket.instances[0].readyState).toBe(1) + await act(async () => Socket.instances[0].close()) + expect([...element.querySelectorAll('button')].find(b => b.textContent?.trim() === 'Reconnect')?.disabled).toBe(true) + await click('New terminal') + expect(new URL(Socket.instances[1].url).searchParams.get('expectedContext')).toBe('A') +}) + +it('opens a recovery shell for the known context when startup reports a config error', async () => { + state.context = 'production@secondary' + state.status = 'disconnected' + await render() + await click('Open') + expect(new URL(Socket.instances[0].url).searchParams.get('expectedContext')).toBe('production@secondary') + await act(async () => { Socket.instances[0].open(); Socket.instances[0].session('production@secondary'); vi.advanceTimersByTime(300) }) + expect(Socket.instances[0].send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1) +}) diff --git a/web/src/components/dock/LocalTerminalTab.test.tsx b/web/src/components/dock/LocalTerminalTab.test.tsx index 7d9a81ca3b..dfe4685c94 100644 --- a/web/src/components/dock/LocalTerminalTab.test.tsx +++ b/web/src/components/dock/LocalTerminalTab.test.tsx @@ -5,21 +5,27 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { DockProvider, useDock } from '../../../../packages/k8s-ui/src/components/dock/DockContext' import type { LocalTerminalTabProps } from '../../../../packages/k8s-ui/src/components/dock/LocalTerminalTab' import { LocalTerminalTab } from './LocalTerminalTab' +import { ToastProvider } from '../../../../packages/k8s-ui/src/components/ui/Toast' const state = vi.hoisted(() => ({ context: 'staging', + firstContext: 'production', props: new Map(), })) vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: state.context } }), })) +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})) vi.mock('@skyhook-io/k8s-ui', async () => { const dock = await import('../../../../packages/k8s-ui/src/components/dock/DockContext') const { ClusterName } = await import('../../../../packages/k8s-ui/src/components/ui/ClusterName') const { Tooltip } = await import('../../../../packages/k8s-ui/src/components/ui/Tooltip') const { parseContextName } = await import('../../../../packages/k8s-ui/src/utils/context-name') + const { useToast } = await import('../../../../packages/k8s-ui/src/components/ui/Toast') return { - ...dock, ClusterName, Tooltip, parseContextName, + ...dock, ClusterName, Tooltip, parseContextName, useToast, LocalTerminalTab: (props: LocalTerminalTabProps) => { state.props.set(props.initialCommand!, props) return
{props.toolbarExtra}
@@ -33,21 +39,22 @@ let element: HTMLDivElement function Harness() { const { tabs, addTab } = useDock() useEffect(() => { - addTab({ type: 'local-terminal', title: 'Terminal', initialCommand: 'first' }) - addTab({ type: 'local-terminal', title: 'Auth', initialCommand: 'second' }) + addTab({ type: 'local-terminal', title: 'Terminal', initialCommand: 'first', localTerminalContext: state.firstContext }) + addTab({ type: 'local-terminal', title: 'Auth', initialCommand: 'second', localTerminalContext: 'staging' }) }, [addTab]) return <>{tabs.map(tab => (

{tab.title}

- +
))} } async function render() { - await act(async () => root.render()) + await act(async () => root.render()) } beforeEach(() => { state.context = 'staging' + state.firstContext = 'production' state.props.clear() element = document.createElement('div') document.body.appendChild(element) @@ -62,40 +69,46 @@ afterEach(async () => { it('waits for server metadata, labels each tab independently, and notices a UI switch', async () => { await render() - expect(element.textContent).toContain('Context not confirmed') - expect(element.querySelector('[role="status"]')).toBeNull() + expect(element.textContent).toContain('Requested:production') await act(async () => { state.props.get('first')!.onSessionInfo!({ context: 'production', kubeconfigIsolated: true }) state.props.get('second')!.onSessionInfo!({ context: 'staging', kubeconfigIsolated: true }) }) expect([...element.querySelectorAll('h2')].map(el => el.textContent)).toEqual(['Terminal · production', 'Auth · staging']) - expect(element.querySelector('[role="status"]')?.getAttribute('aria-label')).toContain('This terminal was opened for production') + expect(element.querySelector('[role="status"]')?.getAttribute('aria-label')).toContain('This terminal is for production') expect(element.querySelector('[role="status"]')?.closest('[data-testid="terminal-toolbar"]')).not.toBeNull() state.context = 'production' await render() const sections = element.querySelectorAll('section') expect(sections[0].querySelector('[role="status"]')).toBeNull() expect(sections[0].textContent).toContain('Opened for:production') - expect(sections[1].querySelector('[role="status"]')?.getAttribute('aria-label')).toContain('This terminal was opened for staging') + expect(sections[1].querySelector('[role="status"]')?.getAttribute('aria-label')).toContain('This terminal is for staging') await act(async () => sections[1].querySelector('button')!.click()) expect([...element.querySelectorAll('h2')].map(el => el.textContent)).toEqual(['Terminal · production', 'Auth · staging', 'Terminal']) }) it('marks fallback sessions unconfirmed and replaces labels for a newly connected shell', async () => { await render() + await act(async () => state.props.get('second')!.onSessionInfo!({ context: 'staging', kubeconfigIsolated: true })) await act(async () => state.props.get('second')!.onSessionInfo!({ context: '', kubeconfigIsolated: false })) const section = element.querySelectorAll('section')[1] expect(section.textContent).toContain('Auth · context not confirmed') expect(section.textContent).toContain('Context not confirmed') expect(section.querySelector('[role="status"]')).toBeNull() + state.context = 'production' + await render() + expect(section.querySelector('[role="status"]')?.textContent).toContain('Requested context differs') + expect(section.querySelector('[role="status"]')?.getAttribute('aria-label')).toContain('Requested for staging; kubeconfig not confirmed') + expect(section.querySelector('[role="status"]')?.getAttribute('aria-label')).not.toContain('This terminal is for staging') await act(async () => state.props.get('second')!.onSessionInfo!(null)) - expect(section.querySelector('h2')?.textContent).toBe('Auth') + expect(section.querySelector('h2')?.textContent).toBe('Auth · context not confirmed') await act(async () => state.props.get('second')!.onSessionInfo!({ context: 'staging', kubeconfigIsolated: true })) expect(section.querySelector('h2')?.textContent).toBe('Auth · staging') }) it('compares full contexts even when both have the same short cluster name', async () => { state.context = 'gke_project-b_us-east1_production' + state.firstContext = 'gke_project-a_us-east1_production' await render() await act(async () => state.props.get('first')!.onSessionInfo!({ context: 'gke_project-a_us-east1_production', kubeconfigIsolated: true })) expect(element.querySelector('h2')?.textContent).toBe('Terminal · production') diff --git a/web/src/components/dock/LocalTerminalTab.tsx b/web/src/components/dock/LocalTerminalTab.tsx index 746648c653..ee79772fca 100644 --- a/web/src/components/dock/LocalTerminalTab.tsx +++ b/web/src/components/dock/LocalTerminalTab.tsx @@ -1,41 +1,50 @@ import { useCallback, useRef, useState } from 'react' import { AlertTriangle } from 'lucide-react' -import { ClusterName, LocalTerminalTab as SharedLocalTerminalTab, Tooltip, parseContextName, useDock, useOpenLocalTerminal, type LocalTerminalSessionInfo } from '@skyhook-io/k8s-ui' +import { useQueryClient } from '@tanstack/react-query' +import { ClusterName, LocalTerminalTab as SharedLocalTerminalTab, Tooltip, parseContextName, useDock, type LocalTerminalSessionInfo } from '@skyhook-io/k8s-ui' import { getWsUrl } from '../../api/config' import { useConnection } from '../../context/ConnectionContext' +import { useOpenLocalTerminal } from './DockContext' interface LocalTerminalTabProps { tabId: string title: string + intendedContext?: string isActive?: boolean initialCommand?: string } -export function LocalTerminalTab({ tabId, title, isActive, initialCommand }: LocalTerminalTabProps) { +export function LocalTerminalTab({ tabId, title, intendedContext, isActive, initialCommand }: LocalTerminalTabProps) { const { connection } = useConnection() + const queryClient = useQueryClient() const { setTabTitle } = useDock() const openLocalTerminal = useOpenLocalTerminal() const baseTitle = useRef(title) const [sessionInfo, setSessionInfo] = useState(null) const handleSessionInfo = useCallback((info: LocalTerminalSessionInfo | null) => { + if (!info) return setSessionInfo(info) - const suffix = info - ? info.kubeconfigIsolated ? parseContextName(info.context).clusterName : 'context not confirmed' - : null - setTabTitle(tabId, suffix ? `${baseTitle.current} · ${suffix}` : baseTitle.current, info?.kubeconfigIsolated ? info.context : undefined) + const suffix = info.kubeconfigIsolated ? parseContextName(info.context).clusterName : 'context not confirmed' + setTabTitle(tabId, `${baseTitle.current} · ${suffix}`, info.kubeconfigIsolated ? info.context : undefined) }, [tabId, setTabTitle]) + const unconfirmed = sessionInfo !== null && !sessionInfo.kubeconfigIsolated + const contextNotice = unconfirmed + ? `Requested for ${intendedContext || 'no active context'}; kubeconfig not confirmed.` + : `This terminal is for ${intendedContext || 'no active context'}.` const context = sessionInfo?.kubeconfigIsolated ? sessionInfo.context : null - const mismatched = context && connection.context && context !== connection.context - const createSession = () => - Promise.resolve({ - wsUrl: getWsUrl('/local-terminal'), - }) + const mismatched = intendedContext !== undefined && !!connection.context && intendedContext !== connection.context + const createSession = async () => { + if (intendedContext === undefined) throw new Error('No context selected. Open a new terminal for the selected context.') + return { wsUrl: getWsUrl(`/local-terminal?expectedContext=${encodeURIComponent(intendedContext)}`) } + } return ( intendedContext !== undefined && connection.context === intendedContext && (!!intendedContext || connection.state === 'disconnected')} + onConnectionError={() => { void queryClient.invalidateQueries({ queryKey: ['connection-status'] }) }} onSessionInfo={handleSessionInfo} toolbarExtra={
@@ -46,6 +55,15 @@ export function LocalTerminalTab({ tabId, title, isActive, initialCommand }: Loc + ) : !sessionInfo && intendedContext ? ( + A new shell has been requested for {intendedContext}.} wrapperClassName="min-w-0"> + + Requested: + + + + ) : intendedContext === undefined ? ( + No context selected ) : ( )} {mismatched && ( - <> - Radar is showing {connection.context}. This terminal was opened for {context}.} wrapperClassName="shrink-0"> - - - Different context - - - Open a new terminal for {connection.context}.} wrapperClassName="shrink-0"> - - - + Radar is showing {connection.context}. {contextNotice} Switch back to reconnect, or open a new terminal.} wrapperClassName="shrink-0"> + + + {unconfirmed ? 'Requested context differs' : 'Different context'} + + + )} + {connection.context && (mismatched || intendedContext === undefined) && ( + Open a new terminal for {connection.context}.} wrapperClassName="shrink-0"> + + )}
}