From c4276b097e54215b7b6477836a2670743149f500 Mon Sep 17 00:00:00 2001 From: damienrj Date: Wed, 2 Sep 2026 00:55:37 -0700 Subject: [PATCH 1/7] feat: add SSH environments from the composer --- src/features/chat/ui/AddRemoteHostDialog.tsx | 132 ++++++++++++++++++ src/features/chat/ui/RemoteHostSelector.tsx | 120 ++++++++++------ .../ui/__tests__/RemoteHostSelector.test.tsx | 72 +++++++++- src/shared/i18n/locales/en/chat.json | 11 ++ 4 files changed, 287 insertions(+), 48 deletions(-) create mode 100644 src/features/chat/ui/AddRemoteHostDialog.tsx diff --git a/src/features/chat/ui/AddRemoteHostDialog.tsx b/src/features/chat/ui/AddRemoteHostDialog.tsx new file mode 100644 index 000000000..f59879024 --- /dev/null +++ b/src/features/chat/ui/AddRemoteHostDialog.tsx @@ -0,0 +1,132 @@ +import { useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; +import { isRemoteBackendError } from "@/shared/api/remoteHosts"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Label } from "@/shared/ui/label"; + +interface AddRemoteHostDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConnected: (host: string) => void; +} + +export function AddRemoteHostDialog({ + open, + onOpenChange, + onConnected, +}: AddRemoteHostDialogProps) { + const { t } = useTranslation("chat"); + const [hostDraft, setHostDraft] = useState(""); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen && pending) return; + onOpenChange(nextOpen); + if (!nextOpen) { + setHostDraft(""); + setError(null); + } + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (pending) return; + + const host = hostDraft.trim(); + if (!host) { + setError(t("toolbar.remoteHost.add.emptyHost")); + return; + } + + setError(null); + setPending(true); + try { + await useRemoteHostStore.getState().ensureHostConnected(host); + onConnected(host); + onOpenChange(false); + setHostDraft(""); + } catch (connectionError) { + setError( + isRemoteBackendError(connectionError) + ? connectionError.message + : String(connectionError), + ); + } finally { + setPending(false); + } + }; + + return ( + + +
+ + {t("toolbar.remoteHost.add.title")} + + {t("toolbar.remoteHost.add.description")} + + +
+ + { + setHostDraft(event.target.value); + if (error) setError(null); + }} + /> + {error ? ( + + ) : null} +
+ + + + +
+
+
+ ); +} diff --git a/src/features/chat/ui/RemoteHostSelector.tsx b/src/features/chat/ui/RemoteHostSelector.tsx index 5b7043d15..13c153b87 100644 --- a/src/features/chat/ui/RemoteHostSelector.tsx +++ b/src/features/chat/ui/RemoteHostSelector.tsx @@ -1,5 +1,7 @@ -import { Laptop, Server } from "lucide-react"; +import { useState } from "react"; +import { Laptop, Plus, Server } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { AddRemoteHostDialog } from "./AddRemoteHostDialog"; import { ChatInputSelector, type ChatInputSelectorItem, @@ -7,6 +9,7 @@ import { import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; const LOCAL_HOST_VALUE = "__local__"; +const ADD_HOST_VALUE = "__add_ssh_environment__"; interface RemoteHostSelectorProps { selectedHost?: string | null; @@ -33,6 +36,7 @@ export function RemoteHostSelector({ modal, }: RemoteHostSelectorProps) { const { t } = useTranslation("chat"); + const [addDialogOpen, setAddDialogOpen] = useState(false); const configHosts = useRemoteHostStore((state) => state.configHosts); const manualHosts = useRemoteHostStore((state) => state.manualHosts); const statusByHost = useRemoteHostStore((state) => state.statusByHost); @@ -65,6 +69,10 @@ export function RemoteHostSelector({ })); const handleValueChange = (value: string) => { + if (value === ADD_HOST_VALUE) { + setAddDialogOpen(true); + return; + } onHostChange?.(value === LOCAL_HOST_VALUE ? null : value); }; @@ -77,52 +85,72 @@ export function RemoteHostSelector({ }; return ( - - ) : ( - - ) - } - open={open} - onOpenChange={handleOpenChange} - onRequestComposerFocus={onRequestComposerFocus} - triggerIconOnly={triggerIconOnly} - triggerVariant="toolbar" - menuLabel={t("toolbar.remoteHost.chooseHost")} - contentWidth="wide" - disabled={disabled} - modal={modal} - sections={[ - { - items: [ - { - value: LOCAL_HOST_VALUE, - label: t("toolbar.remoteHost.thisComputer"), - description: t("toolbar.remoteHost.thisComputerDescription"), - icon: , - }, - ], - }, - ...(hostItems.length > 0 - ? [ + <> + + ) : ( + + ) + } + open={open} + onOpenChange={handleOpenChange} + onRequestComposerFocus={onRequestComposerFocus} + triggerIconOnly={triggerIconOnly} + triggerVariant="toolbar" + menuLabel={t("toolbar.remoteHost.chooseHost")} + contentWidth="wide" + disabled={disabled} + modal={modal} + sections={[ + { + items: [ + { + value: LOCAL_HOST_VALUE, + label: t("toolbar.remoteHost.thisComputer"), + description: t("toolbar.remoteHost.thisComputerDescription"), + icon: , + }, + ], + }, + ...(hostItems.length > 0 + ? [ + { + label: t("toolbar.remoteHost.sshHosts"), + items: hostItems, + }, + ] + : []), + { + items: [ { - label: t("toolbar.remoteHost.sshHosts"), - items: hostItems, + value: ADD_HOST_VALUE, + label: t("toolbar.remoteHost.add.action"), + icon: , }, - ] - : []), - ]} - onValueChange={handleValueChange} - /> + ], + }, + ]} + onValueChange={handleValueChange} + preservesExternalFocus={(value) => value === ADD_HOST_VALUE} + /> + + onHostChange?.(host)} + /> + ); } diff --git a/src/features/chat/ui/__tests__/RemoteHostSelector.test.tsx b/src/features/chat/ui/__tests__/RemoteHostSelector.test.tsx index 33e3879d8..827a0cd41 100644 --- a/src/features/chat/ui/__tests__/RemoteHostSelector.test.tsx +++ b/src/features/chat/ui/__tests__/RemoteHostSelector.test.tsx @@ -1,14 +1,15 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { RemoteHostSelector } from "../RemoteHostSelector"; import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; const mockListSshConfigHosts = vi.fn(); +const mockConnectRemoteHost = vi.fn(); vi.mock("@/shared/api/remoteHosts", () => ({ listSshConfigHosts: (...args: unknown[]) => mockListSshConfigHosts(...args), - connectRemoteHost: vi.fn(), + connectRemoteHost: (...args: unknown[]) => mockConnectRemoteHost(...args), disconnectRemoteHost: vi.fn(), shutdownRemoteHost: vi.fn(), listRemoteBackends: vi.fn().mockResolvedValue([]), @@ -23,9 +24,13 @@ describe("RemoteHostSelector", () => { // Opening the selector refreshes hosts from the SSH config, so the mock // must agree with the seeded store state. mockListSshConfigHosts.mockReset().mockResolvedValue(["devbox", "gpu-box"]); + mockConnectRemoteHost.mockReset().mockResolvedValue(undefined); useRemoteHostStore.setState({ configHosts: ["devbox", "gpu-box"], + manualHosts: [], statusByHost: { devbox: { state: "ready" } }, + forgottenHosts: {}, + lifecycleByHost: {}, }); }); @@ -82,4 +87,67 @@ describe("RemoteHostSelector", () => { screen.getByRole("menuitem", { name: /devbox/i }), ).toBeInTheDocument(); }); + + it("offers an add SSH environment action even when no hosts are configured", async () => { + mockListSshConfigHosts.mockResolvedValue([]); + useRemoteHostStore.setState({ configHosts: [], statusByHost: {} }); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /select computer/i })); + + expect( + screen.getByRole("menuitem", { name: /add ssh environment/i }), + ).toBeInTheDocument(); + }); + + it("connects and selects a host added from the environment dialog", async () => { + const user = userEvent.setup(); + const onHostChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: /select computer/i })); + await user.click( + screen.getByRole("menuitem", { name: /add ssh environment/i }), + ); + await user.type(screen.getByRole("textbox", { name: /ssh host/i }), "blox"); + await user.click(screen.getByRole("button", { name: /^connect$/i })); + + await waitFor(() => { + expect(mockConnectRemoteHost).toHaveBeenCalledWith("blox"); + expect(onHostChange).toHaveBeenCalledWith("blox"); + }); + expect( + screen.queryByRole("dialog", { name: /add ssh environment/i }), + ).not.toBeInTheDocument(); + }); + + it("keeps the add dialog open with feedback when connecting fails", async () => { + mockConnectRemoteHost.mockRejectedValue(new Error("SSH host unavailable")); + const user = userEvent.setup(); + const onHostChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: /select computer/i })); + await user.click( + screen.getByRole("menuitem", { name: /add ssh environment/i }), + ); + await user.type( + screen.getByRole("textbox", { name: /ssh host/i }), + "offline-box", + ); + await user.click(screen.getByRole("button", { name: /^connect$/i })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "SSH host unavailable", + ); + expect(onHostChange).not.toHaveBeenCalled(); + expect( + screen.getByRole("dialog", { name: /add ssh environment/i }), + ).toBeInTheDocument(); + }); }); diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index 103dc0536..2bf514c5d 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -536,6 +536,17 @@ "thisComputer": "This computer", "thisComputerDescription": "Run the session locally", "sshHosts": "SSH hosts", + "add": { + "action": "Add SSH environment", + "title": "Add SSH environment", + "description": "Connect using an SSH config alias or a user@host address.", + "hostLabel": "SSH host", + "hostPlaceholder": "user@host", + "emptyHost": "Enter an SSH host.", + "cancel": "Cancel", + "connect": "Connect", + "close": "Close add SSH environment dialog" + }, "localTriggerTitle": "Runs on this computer", "remoteTriggerTitle": "Runs on {{host}} over SSH", "missingDirectory": "Choose a folder on the remote host before sending", From b768834ca116a851c4c8942861f5d0a6ae54b7f0 Mon Sep 17 00:00:00 2001 From: damienrj Date: Wed, 2 Sep 2026 11:29:45 -0700 Subject: [PATCH 2/7] fix: namespace remote host selector values --- src/features/chat/ui/RemoteHostSelector.tsx | 21 +++++++++--- .../ui/__tests__/RemoteHostSelector.test.tsx | 32 ++++++++++++++++++- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/features/chat/ui/RemoteHostSelector.tsx b/src/features/chat/ui/RemoteHostSelector.tsx index 13c153b87..bbb1b338b 100644 --- a/src/features/chat/ui/RemoteHostSelector.tsx +++ b/src/features/chat/ui/RemoteHostSelector.tsx @@ -8,8 +8,13 @@ import { } from "./ChatInputSelector"; import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; -const LOCAL_HOST_VALUE = "__local__"; -const ADD_HOST_VALUE = "__add_ssh_environment__"; +const LOCAL_HOST_VALUE = "action:local"; +const ADD_HOST_VALUE = "action:add-ssh-environment"; +const HOST_VALUE_PREFIX = "host:"; + +function hostValue(host: string): string { + return `${HOST_VALUE_PREFIX}${host}`; +} interface RemoteHostSelectorProps { selectedHost?: string | null; @@ -62,7 +67,7 @@ export function RemoteHostSelector({ }; const hostItems: ChatInputSelectorItem[] = listedHosts.map((host) => ({ - value: host, + value: hostValue(host), label: host, description: statusDescription(host), icon: , @@ -73,7 +78,13 @@ export function RemoteHostSelector({ setAddDialogOpen(true); return; } - onHostChange?.(value === LOCAL_HOST_VALUE ? null : value); + if (value === LOCAL_HOST_VALUE) { + onHostChange?.(null); + return; + } + if (value.startsWith(HOST_VALUE_PREFIX)) { + onHostChange?.(value.slice(HOST_VALUE_PREFIX.length)); + } }; const handleOpenChange = (nextOpen: boolean) => { @@ -88,7 +99,7 @@ export function RemoteHostSelector({ <> { // Opening the selector refreshes hosts from the SSH config, so the mock // must agree with the seeded store state. mockListSshConfigHosts.mockReset().mockResolvedValue(["devbox", "gpu-box"]); - mockConnectRemoteHost.mockReset().mockResolvedValue(undefined); + mockConnectRemoteHost.mockReset().mockResolvedValue({ + incarnation: "slot-1", + generation: 1, + }); useRemoteHostStore.setState({ configHosts: ["devbox", "gpu-box"], manualHosts: [], @@ -75,6 +78,33 @@ describe("RemoteHostSelector", () => { expect(onHostChange).toHaveBeenCalledWith(null); }); + it("treats aliases matching the former action sentinels as SSH hosts", async () => { + const aliases = ["__local__", "__add_ssh_environment__"]; + mockListSshConfigHosts.mockResolvedValue(aliases); + useRemoteHostStore.setState({ configHosts: aliases }); + const user = userEvent.setup(); + const onHostChange = vi.fn(); + const { unmount } = render( + , + ); + + await user.click(screen.getByRole("button", { name: /select computer/i })); + await user.click(screen.getByRole("menuitem", { name: "__local__" })); + expect(onHostChange).toHaveBeenLastCalledWith("__local__"); + unmount(); + + onHostChange.mockClear(); + render( + , + ); + await user.click(screen.getByRole("button", { name: /select computer/i })); + await user.click( + screen.getByRole("menuitem", { name: "__add_ssh_environment__" }), + ); + expect(onHostChange).toHaveBeenLastCalledWith("__add_ssh_environment__"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + it("still lists a selected host that is missing from the SSH config", async () => { mockListSshConfigHosts.mockResolvedValue(["gpu-box"]); useRemoteHostStore.setState({ configHosts: ["gpu-box"] }); From 00c318d893a9b93a1c129e5629c2971ff63b7c10 Mon Sep 17 00:00:00 2001 From: damienrj Date: Wed, 2 Sep 2026 12:48:47 -0700 Subject: [PATCH 3/7] fix: persist ready manual ssh hosts --- .../remoteHosts/stores/remoteHostStore.test.ts | 17 +++++++++++++++++ .../remoteHosts/stores/remoteHostStore.ts | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/features/remoteHosts/stores/remoteHostStore.test.ts b/src/features/remoteHosts/stores/remoteHostStore.test.ts index 3e14743db..1f6fc7cc7 100644 --- a/src/features/remoteHosts/stores/remoteHostStore.test.ts +++ b/src/features/remoteHosts/stores/remoteHostStore.test.ts @@ -251,6 +251,23 @@ describe("ensureHostConnected", () => { expect(mocks.connectRemoteHost).not.toHaveBeenCalled(); }); + it("remembers a manually entered host when its backend is already ready", async () => { + useRemoteHostStore.setState({ configHosts: ["configured"] }); + useRemoteHostStore.getState().applyStatusEvent({ + host: "workstation.blox", + ...backendIdentity, + state: "ready", + }); + + await useRemoteHostStore.getState().ensureHostConnected("workstation.blox"); + + expect(mocks.connectRemoteHost).not.toHaveBeenCalled(); + expect(useRemoteHostStore.getState().manualHosts).toEqual([ + "workstation.blox", + ]); + expect(loadPersistedManualHosts()).toEqual(["workstation.blox"]); + }); + it("connects and marks the host ready", async () => { let resolveConnect: (value: RemoteBackendConnection) => void = () => {}; mocks.connectRemoteHost.mockImplementation( diff --git a/src/features/remoteHosts/stores/remoteHostStore.ts b/src/features/remoteHosts/stores/remoteHostStore.ts index 88b1c245a..16b8ca6ff 100644 --- a/src/features/remoteHosts/stores/remoteHostStore.ts +++ b/src/features/remoteHosts/stores/remoteHostStore.ts @@ -272,6 +272,20 @@ export const useRemoteHostStore = create((set, get) => ({ current.statusByHost[host]?.state === "ready" && !current.forgottenHosts[host] ) { + // A manually entered host can already be ready when it was restored + // from the backend snapshot. Remember it even though no new connect is + // required, otherwise it disappears from the selector after restart. + if ( + !current.configHosts.includes(host) && + !current.manualHosts.includes(host) + ) { + const manualHosts = [host, ...current.manualHosts].slice( + 0, + MAX_MANUAL_HOSTS, + ); + persistManualHosts(manualHosts); + set({ manualHosts }); + } return; } From 2bd552cf816b50d1a06a109c4e180097154c686a Mon Sep 17 00:00:00 2001 From: damienrj Date: Wed, 2 Sep 2026 15:36:49 -0700 Subject: [PATCH 4/7] fix: keep pending SSH dialog dismissible --- src/features/chat/ui/AddRemoteHostDialog.tsx | 32 ++++++++++----- .../ui/__tests__/RemoteHostSelector.test.tsx | 41 +++++++++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/features/chat/ui/AddRemoteHostDialog.tsx b/src/features/chat/ui/AddRemoteHostDialog.tsx index f59879024..8ac07877f 100644 --- a/src/features/chat/ui/AddRemoteHostDialog.tsx +++ b/src/features/chat/ui/AddRemoteHostDialog.tsx @@ -1,4 +1,4 @@ -import { useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; import { useTranslation } from "react-i18next"; import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; import { isRemoteBackendError } from "@/shared/api/remoteHosts"; @@ -29,13 +29,24 @@ export function AddRemoteHostDialog({ const [hostDraft, setHostDraft] = useState(""); const [error, setError] = useState(null); const [pending, setPending] = useState(false); + const attemptRef = useRef(0); + + useEffect( + () => () => { + attemptRef.current += 1; + }, + [], + ); const handleOpenChange = (nextOpen: boolean) => { - if (!nextOpen && pending) return; onOpenChange(nextOpen); if (!nextOpen) { + // The backend connection may still finish, but closing the dialog must + // keep that late result from changing the composer's selected host. + attemptRef.current += 1; setHostDraft(""); setError(null); + setPending(false); } }; @@ -51,29 +62,29 @@ export function AddRemoteHostDialog({ setError(null); setPending(true); + const attempt = ++attemptRef.current; try { await useRemoteHostStore.getState().ensureHostConnected(host); + if (attemptRef.current !== attempt) return; onConnected(host); - onOpenChange(false); - setHostDraft(""); + handleOpenChange(false); } catch (connectionError) { + if (attemptRef.current !== attempt) return; setError( isRemoteBackendError(connectionError) ? connectionError.message : String(connectionError), ); } finally { - setPending(false); + if (attemptRef.current === attempt) { + setPending(false); + } } }; return ( - +
{t("toolbar.remoteHost.add.title")} @@ -111,7 +122,6 @@ export function AddRemoteHostDialog({