Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions src/features/chat/ui/AddRemoteHostDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
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";
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<string | null>(null);
const [pending, setPending] = useState(false);
const attemptRef = useRef(0);

useEffect(
() => () => {
attemptRef.current += 1;
},
[],
);

const handleOpenChange = (nextOpen: boolean) => {
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);
}
};

const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (pending) return;

const host = hostDraft.trim();
Comment thread
damienrj marked this conversation as resolved.
if (!host) {
setError(t("toolbar.remoteHost.add.emptyHost"));
return;
}

setError(null);
setPending(true);
const attempt = ++attemptRef.current;
try {
const outcome = await useRemoteHostStore
.getState()
.ensureHostConnected(host);
if (attemptRef.current !== attempt) return;
if (outcome === "superseded") {
setError(t("toolbar.remoteHost.add.superseded"));
return;
}
onConnected(host);
handleOpenChange(false);
} catch (connectionError) {
if (attemptRef.current !== attempt) return;
setError(
isRemoteBackendError(connectionError)
? connectionError.message
: String(connectionError),
);
} finally {
if (attemptRef.current === attempt) {
setPending(false);
}
}
};

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent size="md" closeLabel={t("toolbar.remoteHost.add.close")}>
<form className="contents" onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{t("toolbar.remoteHost.add.title")}</DialogTitle>
<DialogDescription>
{t("toolbar.remoteHost.add.description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="add-ssh-environment-host">
{t("toolbar.remoteHost.add.hostLabel")}
</Label>
<Input
id="add-ssh-environment-host"
value={hostDraft}
placeholder={t("toolbar.remoteHost.add.hostPlaceholder")}
disabled={pending}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "add-ssh-environment-error" : undefined}
onChange={(event) => {
setHostDraft(event.target.value);
if (error) setError(null);
}}
/>
{error ? (
<p
id="add-ssh-environment-error"
className="text-sm text-destructive"
role="alert"
>
{error}
</p>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
>
{t("toolbar.remoteHost.add.cancel")}
</Button>
<Button
type="submit"
feedbackState={pending ? "loading" : "idle"}
loadingLabel={t("toolbar.remoteHost.status.connecting")}
preserveWidth
>
{t("toolbar.remoteHost.add.connect")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
137 changes: 88 additions & 49 deletions src/features/chat/ui/RemoteHostSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
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,
} from "./ChatInputSelector";
import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore";

const LOCAL_HOST_VALUE = "__local__";
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;
Expand All @@ -33,6 +41,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);
Expand All @@ -58,14 +67,24 @@ export function RemoteHostSelector({
};

const hostItems: ChatInputSelectorItem[] = listedHosts.map((host) => ({
value: host,
value: hostValue(host),
label: host,
description: statusDescription(host),
icon: <Server className="size-4 text-foreground" />,
}));

const handleValueChange = (value: string) => {
onHostChange?.(value === LOCAL_HOST_VALUE ? null : value);
if (value === ADD_HOST_VALUE) {
setAddDialogOpen(true);
return;
}
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) => {
Expand All @@ -77,52 +96,72 @@ export function RemoteHostSelector({
};

return (
<ChatInputSelector
ariaLabel={t("toolbar.remoteHost.selectHost")}
value={selectedHost ?? LOCAL_HOST_VALUE}
triggerLabel={selectedHost ?? t("toolbar.remoteHost.thisComputer")}
triggerTitle={
selectedHost
? t("toolbar.remoteHost.remoteTriggerTitle", { host: selectedHost })
: t("toolbar.remoteHost.localTriggerTitle")
}
icon={
selectedHost ? (
<Server className="size-4" />
) : (
<Laptop className="size-4" />
)
}
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: <Laptop className="size-4 text-foreground" />,
},
],
},
...(hostItems.length > 0
? [
<>
<ChatInputSelector
ariaLabel={t("toolbar.remoteHost.selectHost")}
value={selectedHost ? hostValue(selectedHost) : LOCAL_HOST_VALUE}
triggerLabel={selectedHost ?? t("toolbar.remoteHost.thisComputer")}
triggerTitle={
selectedHost
? t("toolbar.remoteHost.remoteTriggerTitle", {
host: selectedHost,
})
: t("toolbar.remoteHost.localTriggerTitle")
}
icon={
selectedHost ? (
<Server className="size-4" />
) : (
<Laptop className="size-4" />
)
}
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: <Laptop className="size-4 text-foreground" />,
},
],
},
...(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: <Plus className="size-4 text-foreground" />,
},
]
: []),
]}
onValueChange={handleValueChange}
/>
],
},
]}
onValueChange={handleValueChange}
preservesExternalFocus={(value) => value === ADD_HOST_VALUE}
/>

<AddRemoteHostDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
onConnected={(host) => onHostChange?.(host)}
/>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ describe("RemoteHostConnectionBanner", () => {
it("reconnects the host and reloads the session transcript", async () => {
const ensureHostConnected = vi.fn(async () => {
setHostState("ready");
return "connected" as const;
});
const original = useRemoteHostStore.getState().ensureHostConnected;
useRemoteHostStore.setState({ ensureHostConnected });
Expand Down
Loading