diff --git a/.changeset/ssh-in-subshells.md b/.changeset/ssh-in-subshells.md new file mode 100644 index 000000000..b0f7245f8 --- /dev/null +++ b/.changeset/ssh-in-subshells.md @@ -0,0 +1,5 @@ +--- +"@internal/server": patch +--- + +Open SSH terminals from New subshell, workspace additions, and splits. Explain the destination and connecting machine, move alternate keys under advanced options, and approve keys inside the launch dialog without losing workspace context. Existing Connect links open the shared subshell launcher. The selected subshell type has a distinct highlight and checkmark, with initial focus following the selected type. diff --git a/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts index 3f1b89b32..6a70bd5ed 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts @@ -203,7 +203,15 @@ describe("ssh_exec kick + status", () => { expect(await execSshExecStatus(ctx, statusCmd(), seams)).toEqual({ ok: true, data: { state: "running" } }); finish({ code: 0, stdout: "==> downloading subshell\n==> done.\n", stderr: "" }); - await new Promise((r) => setTimeout(r, 5)); // let the off-chain completion land + // Completion schedules asynchronous file removal; a fixed 5 ms sleep + // races filesystem work under parallel suite load. + for ( + let attempt = 0; + attempt < 100 && (existsSync(configPath) || existsSync(buildSshKnownHostsPath(ctx.config.dataDir, EXEC_ID))); + attempt++ + ) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } expect(await execSshExecStatus(ctx, statusCmd(), seams)).toEqual({ ok: true, data: { state: "done", code: 0, timedOut: false, stdout: "==> downloading subshell\n==> done.\n", stderr: "" }, diff --git a/apps/server/web/AGENTS.md b/apps/server/web/AGENTS.md index 7db19c2dd..5279614a4 100644 --- a/apps/server/web/AGENTS.md +++ b/apps/server/web/AGENTS.md @@ -386,12 +386,13 @@ probe gates): read apps/server/web/docs/terminal-gotchas.md first.** ## SSH relay launch and setup -Connect can use another owned node's loaded SSH-agent keys. The default omits +The SSH terminal choice in New subshell can use another owned node's loaded SSH-agent keys. The default omits `keyHome` and uses the connecting machine's own keys. Never silently substitute -keys when the chosen machine goes offline. Approval links preserve the launch -choices and require a fresh Connect click; approval itself launches nothing. +keys when the chosen machine goes offline. Inline approval preserves the launch choices and workspace/split context, +and requires a fresh Start SSH subshell click; approval itself launches nothing. Approval return links bind the preserved choices to the exact request ID, never just the machine pair: several destinations can await approval on that pair. +Old `/connect` links open the same subshell dialog through `/new?kind=ssh`. The pending-request view carries the exact destination account and port, and new requests have no pane yet, so the approvals list must not link a nonexistent pane. Failed settings reads must stay distinct from empty lists. diff --git a/apps/server/web/docs/launch-form-picker.md b/apps/server/web/docs/launch-form-picker.md index a8c468ec4..69edc82b6 100644 --- a/apps/server/web/docs/launch-form-picker.md +++ b/apps/server/web/docs/launch-form-picker.md @@ -154,3 +154,21 @@ Mobile's New screen does NOT mirror this tier (operator scope call: web only; `agent-default.ts` keeps its "change one, change both" for the AGENT rule only). + +## SSH subshells + +New subshell, Add/Split, and New workspace offer the same `SubshellKindPicker`: +Agent or terminal, or SSH terminal. The SSH choice uses `ConnectPanel`, with +callbacks that attach the created subshell to the caller's workspace instead +of navigating away. Keep launch pending until that attachment finishes. + +The SSH destination accepts typed text directly; suggestions retain their +canonical destination until edited. Explain the connecting machine beside its +picker. Alternate key machines belong under Advanced SSH options. Approval +runs inside the same dialog, scoped to the exact request, and returns to the +form without launching automatically. This preserves workspace selections and +split direction. Saved destination management lives in Settings → SSH. + +`/connect` is a compatibility redirect to `/new?kind=ssh`, preserving validated +connection choices. The quick-add provider opens the shared dialog over the +subshell list; there is no separate Connect navigation item or page. diff --git a/apps/server/web/src/components/__tests__/app-sidebar-account.test.tsx b/apps/server/web/src/components/__tests__/app-sidebar-account.test.tsx index ddce171c2..a8e5668f7 100644 --- a/apps/server/web/src/components/__tests__/app-sidebar-account.test.tsx +++ b/apps/server/web/src/components/__tests__/app-sidebar-account.test.tsx @@ -112,6 +112,7 @@ describe("the header account card", () => { const calls = stubFetch(opts); const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openSshLaunch: () => {}, openNewWorkspace: () => {}, }); try { diff --git a/apps/server/web/src/components/__tests__/app-sidebar-group.test.tsx b/apps/server/web/src/components/__tests__/app-sidebar-group.test.tsx index 9911f25c0..b20ef4d77 100644 --- a/apps/server/web/src/components/__tests__/app-sidebar-group.test.tsx +++ b/apps/server/web/src/components/__tests__/app-sidebar-group.test.tsx @@ -104,6 +104,7 @@ describe("the Server Settings group's open/close wiring", () => { const restoreFetch = stubFetch(true); const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openSshLaunch: () => {}, openNewWorkspace: () => {}, }); try { @@ -181,6 +182,7 @@ describe("the Server Settings group's open/close wiring", () => { const restoreFetch = stubFetch(false); const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openSshLaunch: () => {}, openNewWorkspace: () => {}, }); try { @@ -217,6 +219,7 @@ describe("the Settings group", () => { const restoreFetch = stubFetch(false); // NOT an admin: the group is ungated const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openSshLaunch: () => {}, openNewWorkspace: () => {}, }); try { diff --git a/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx b/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx index 0592209d8..431751489 100644 --- a/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx +++ b/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx @@ -187,6 +187,7 @@ const withRail = async ( const restoreFetch = stubFetch(subshells, state); const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openSshLaunch: () => {}, openNewWorkspace: () => {}, }); try { @@ -652,7 +653,11 @@ describe("the eye toggle (operator ask 2026-09-27)", () => { it("reads a hidden section back from storage on the next mount", async () => { localStorage.setItem(HIDDEN_KEY, JSON.stringify({ subshells: true })); const restore = stubFetch([subshell({ id: "a", name: "one", nodeId: "local" })]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail(); // The eye is ALWAYS drawn (it is the way back); it says Show, and the @@ -693,7 +698,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { draft("w1", "2026-08-28T16:45:00.000Z"), draft("w2", "2026-09-01T09:05:00.000Z"), ]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/"); await waitFor(() => expect(document.querySelector("a[href='/workspaces/w1']")).toBeTruthy()); @@ -709,7 +718,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { it("highlights the draft you are standing in", async () => { const restore = stubFetch([], { failNodes: false }, [], undefined, [draft("w1", "2026-08-28T16:45:00.000Z")]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/workspaces/w1"); await waitFor(() => expect(document.querySelector("a[href='/workspaces/w1']")).toBeTruthy()); @@ -724,7 +737,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { it("collapses a Workspaces section on its header and remembers it", async () => { const restore = stubFetch([], { failNodes: false }, [], undefined, [draft("w1", "2026-08-28T16:45:00.000Z")]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/"); await waitFor(() => expect(document.getElementById("ws-group-drafts")).toBeTruthy()); @@ -759,7 +776,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { updatedAt: "2026-09-25T00:00:00.000Z", }; const restore = stubFetch([], { failNodes: false }, [saved], undefined, [draft("d1", "2026-08-28T16:45:00.000Z")]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/workspaces/s1"); await waitFor(() => expect(document.querySelector("a[href='/workspaces/d1']")).toBeTruthy()); @@ -778,7 +799,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { draft("w1", "2026-08-28T16:45:00.000Z"), draft("w2", "2026-08-28T16:45:30.000Z"), ]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/"); await waitFor(() => expect(document.querySelector("a[href='/workspaces/w2']")).toBeTruthy()); @@ -800,7 +825,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { // so a user with nothing saved typing a non-matching query unmounted the // section — the input they were typing in — with no way to clear it. const restore = stubFetch([], { failNodes: false }, [], undefined, [draft("d1", "2026-08-28T16:45:00.000Z")]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/"); const input = await screen.findByLabelText("Filter workspaces"); @@ -824,7 +853,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => { updatedAt: "2026-09-25T00:00:00.000Z", }; const restore = stubFetch([], { failNodes: false }, [saved]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/"); await waitFor(() => expect(screen.getByText("My saved")).toBeTruthy()); @@ -872,7 +905,11 @@ describe("the workspace search filter (operator ask 2026-09-27)", () => { draft("d1", "Doc-thing", "2026-08-28T16:45:00.000Z"), draft("d2", "Other", "2026-08-28T16:45:30.000Z"), ]); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail("/"); const savedHeader = () => screen.getByRole("button", { name: /^Saved/ }) as HTMLButtonElement; @@ -898,7 +935,11 @@ describe("the workspace search filter (operator ask 2026-09-27)", () => { it("filters the workspace list as you type, over the whole set", async () => { const rows = [ws("w1", "Alpha", "2026-09-20T00:00:00.000Z"), ws("w2", "Docs", "2026-09-25T00:00:00.000Z")]; const restore = stubFetch([], { failNodes: false }, rows); - const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} }); + const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ + openLaunch: () => {}, + openSshLaunch: () => {}, + openNewWorkspace: () => {}, + }); try { await renderRail(); const input = await screen.findByLabelText("Filter workspaces"); diff --git a/apps/server/web/src/components/__tests__/app-sidebar.test.ts b/apps/server/web/src/components/__tests__/app-sidebar.test.ts index b0577d05e..157dac278 100644 --- a/apps/server/web/src/components/__tests__/app-sidebar.test.ts +++ b/apps/server/web/src/components/__tests__/app-sidebar.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { Cable, Fingerprint, ServerCog, Settings, SlidersHorizontal } from "lucide-react"; +import { Fingerprint, ServerCog, Settings, SlidersHorizontal } from "lucide-react"; import { isNavGroup, visibleNavEntries, visibleNavItems } from "@/components/sidebar/sidebar-nav"; describe("sidebar nav icons (spec 2026-09-02 §3, 2026-09-11 §3.1)", () => { @@ -20,10 +20,8 @@ describe("sidebar nav icons (spec 2026-09-02 §3, 2026-09-11 §3.1)", () => { expect(entries.filter(isNavGroup).find((g) => g.id === "server-settings")?.icon).toBe(ServerCog); }); - it("Connect takes Cable, beside Subshells as a primary action", () => { - // The Connect page is a top-level action (spec 2026-10-07 §7), not a - // settings page; Cable was free of the rail when it landed. - expect(items.find((i) => i.to === "/connect")?.icon).toBe(Cable); + it("SSH launches with subshells rather than a separate navigation page", () => { + expect(items.some((i) => i.to === "/connect")).toBe(false); }); it("SSH takes Fingerprint, in the personal Settings group (its ledger is per-owner)", () => { diff --git a/apps/server/web/src/components/__tests__/open-in-browser.test.tsx b/apps/server/web/src/components/__tests__/open-in-browser.test.tsx index e09567bb0..2361d0952 100644 --- a/apps/server/web/src/components/__tests__/open-in-browser.test.tsx +++ b/apps/server/web/src/components/__tests__/open-in-browser.test.tsx @@ -103,6 +103,7 @@ function stubFetch() { // provider would mount two dialogs and their data, none of it under test. const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openSshLaunch: () => {}, openNewWorkspace: () => {}, }); return () => { diff --git a/apps/server/web/src/components/__tests__/quick-add.test.tsx b/apps/server/web/src/components/__tests__/quick-add.test.tsx index 8480c6f3a..218a09f0b 100644 --- a/apps/server/web/src/components/__tests__/quick-add.test.tsx +++ b/apps/server/web/src/components/__tests__/quick-add.test.tsx @@ -74,7 +74,7 @@ describe("QuickAddProvider", () => { await act(async () => { screen.getByText("rail + (subshell)").click(); }); - expect(await screen.findByText("Launch an agent in a working directory.")).toBeDefined(); + expect(await screen.findByText("Start an agent, a terminal, or an SSH session in a subshell.")).toBeDefined(); // The workspace dialog stays closed — the two triggers are independent. expect(screen.queryByText("Start it with subshells already tiled in, or empty.")).toBeNull(); await flush(); @@ -86,7 +86,7 @@ describe("QuickAddProvider", () => { screen.getByText("rail + (workspace)").click(); }); expect(await screen.findByText("Start it with subshells already tiled in, or empty.")).toBeDefined(); - expect(screen.queryByText("Launch an agent in a working directory.")).toBeNull(); + expect(screen.queryByText("Start an agent, a terminal, or an SSH session in a subshell.")).toBeNull(); await flush(); }); diff --git a/apps/server/web/src/components/__tests__/sidebar-nav.test.ts b/apps/server/web/src/components/__tests__/sidebar-nav.test.ts index bc30b93ac..51b6593af 100644 --- a/apps/server/web/src/components/__tests__/sidebar-nav.test.ts +++ b/apps/server/web/src/components/__tests__/sidebar-nav.test.ts @@ -39,14 +39,14 @@ describe("visibleNavItems", () => { // `/settings/ssh` rides the PERSONAL group: its path is under // /settings but the page is the caller's own ledger (spec 2026-10-08 // §8), so a member reaches it and the gate never hides it. - expect(paths).toEqual(["/", "/connect", "/workspaces", "/nodes", "/presets", "/prompts", "/settings/ssh"]); + expect(paths).toEqual(["/", "/workspaces", "/nodes", "/presets", "/prompts", "/settings/ssh"]); } }); it("gives an admin every group page, flattened in rail order", () => { expect(visibleNavItems(true).map((i) => i.to)).toEqual([ "/", - "/connect", + "/workspaces", "/nodes", "/presets", diff --git a/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx index ae3a618f0..295bbe1f6 100644 --- a/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx +++ b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx @@ -162,7 +162,7 @@ function machineInput(): HTMLInputElement { } function destinationInput(): HTMLInputElement { - return screen.getByPlaceholderText("Choose or type a destination") as HTMLInputElement; + return screen.getByPlaceholderText("user@hostname:22 or an SSH alias") as HTMLInputElement; } /** The destination field opens on focus (the working-directory posture). */ @@ -181,7 +181,7 @@ async function commitTyped(text: string): Promise { } async function clickConnect(): Promise { - fireEvent.click(screen.getByRole("button", { name: /^Connect$/ })); + fireEvent.click(screen.getByRole("button", { name: /^Start SSH subshell$/ })); await settle(); } @@ -277,7 +277,7 @@ describe("machine disclosure (contract 1)", () => { const caption = screen.getByText("No SSH-enabled machine is available. Enable SSH on a machine first."); expect(caption.className).toContain("text-amber-600"); // gold: nothing chosen, not a refused value await commitTyped("box.example"); - const button = screen.getByRole("button", { name: /^Connect$/ }) as HTMLButtonElement; + const button = screen.getByRole("button", { name: /^Start SSH subshell$/ }) as HTMLButtonElement; expect(button.disabled).toBe(true); // the machine is in the disabled condition await clickConnect(); expect(calls.filter((c) => c.url === "/api/ssh/launch")).toEqual([]); @@ -370,10 +370,10 @@ describe("destination field (contract 2)", () => { } }); - it("accepts a host typed that no list carries: the mirror row commits it", async () => { + it("accepts a typed host directly without requiring a suggestion click", async () => { const { calls, restore } = await renderPanel({ nodes: [HOST_A] }); try { - await commitTyped("box.example"); + fireEvent.change(destinationInput(), { target: { value: "box.example" } }); expect(destinationInput().value).toBe("box.example"); // the held echo is what will launch await clickConnect(); const post = calls.find((c) => c.url === "/api/ssh/launch"); @@ -437,7 +437,7 @@ describe("connect and the ledger (contracts 3, 6)", () => { await settle(); try { await commitTyped("box.example"); - fireEvent.click(screen.getByRole("button", { name: /^Connect$/ })); + fireEvent.click(screen.getByRole("button", { name: /^Start SSH subshell$/ })); await settle(); const button = screen.getByRole("button", { name: /Connecting/ }); // no spinner: the button owns the wait expect((button as HTMLButtonElement).disabled).toBe(true); @@ -530,7 +530,7 @@ describe("refusals and disclosure (contracts 4, 5)", () => { } }); - it("ships the two-sentence disclosure under the destination field, wired as its description", async () => { + it("explains the destination format and discloses SSH config execution before launch", async () => { const { restore } = await renderPanel({ nodes: [HOST_A] }); try { const line = await screen.findByText(SSH_DISCLOSURE_COPY); @@ -538,7 +538,7 @@ describe("refusals and disclosure (contracts 4, 5)", () => { expect(destinationInput().getAttribute("aria-describedby")).toBe("connect-destination-disclosure"); // The exact sentences (decision 6), dash-free. expect(SSH_DISCLOSURE_COPY).toBe( - "Resolving asks the connecting machine to read its SSH config. A hidden Match exec in that config can run a local command while it resolves.", + "Connecting uses this machine’s SSH configuration, including any local commands configured with Match exec.", ); } finally { restore(); @@ -590,10 +590,7 @@ describe("relay launch and approval recovery", () => { ); try { await clickConnect(); - const link = screen.getByRole("link", { name: "Review SSH approval" }) as HTMLAnchorElement; - expect(new URL(link.href).searchParams.get("requestId")).toBe("req1"); - expect(link.href).toContain("/settings/ssh"); - expect(link.href).toContain("keyHome"); + expect(screen.getByRole("button", { name: "Review SSH approval" })).toBeTruthy(); expect(screen.queryByText(/has no live connection/)).toBeNull(); } finally { restore(); @@ -605,7 +602,7 @@ describe("relay launch and approval recovery", () => { { node: HOST_A.id, keyHome: HOST_C.id, destination: "work" }, ); try { - expect((screen.getByRole("button", { name: "Connect" }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole("button", { name: "Start SSH subshell" }) as HTMLButtonElement).disabled).toBe(true); expect(screen.getByText(/selected key machine is unavailable/)).toBeTruthy(); expect(calls.some((c) => c.url === "/api/ssh/launch")).toBe(false); } finally { diff --git a/apps/server/web/src/components/connect/connect-panel.tsx b/apps/server/web/src/components/connect/connect-panel.tsx index c6405fbca..7bbd9c819 100644 --- a/apps/server/web/src/components/connect/connect-panel.tsx +++ b/apps/server/web/src/components/connect/connect-panel.tsx @@ -1,12 +1,14 @@ import type { Node } from "@internal/node-admin"; import { Button, Label } from "@internal/node-admin"; import { Link, useNavigate } from "@tanstack/react-router"; +import { ChevronDown } from "lucide-react"; import { type JSX, useEffect, useState } from "react"; import { DestinationField } from "@/components/connect/destination-field"; import { buildDestinationOptions, type DestinationCandidate } from "@/components/connect/destination-options"; -import { SavedHostsSection } from "@/components/connect/saved-hosts-section"; import { type SshRefusalCopy, sshLaunchRefusal } from "@/components/connect/ssh-refusal"; +import { PendingApprovals } from "@/components/ssh/pending-approvals"; import { type ComboboxOption, SearchableSelect } from "@/components/ui/combobox"; +import { Field, FieldGroup } from "@/components/ui/field"; import { RequiredMark } from "@/components/ui/required-mark"; import { useNodes } from "@/hooks/use-nodes"; import { useLaunchSsh, useSaveSshHost, useSetDefaultNode, useSshAliases, useSshSavedHosts } from "@/hooks/use-ssh"; @@ -15,37 +17,9 @@ import { nodeOptionLabel } from "@/lib/node-label"; import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone"; import { launchableNodes } from "@/lib/subshell-compat"; -/** - * The destination-first Connect flow (spec 2026-10-07 §7, decision 2): the - * destination leads, the connecting machine follows as a disclosure line, - * and one POST opens the pane - the SERVER resolves, so selecting a row is - * display, never a pre-resolve step. Three nouns only (the §11 vocabulary): - * destination, connecting machine. The key-source picker adds M2 relay access while keeping direct SSH the default. - * - * A machine whose SSH is off stays VISIBLE and disabled with its reason and - * remedy - greying explains, hiding does not (decision 3). Once both feeds - * have answered, an empty picker IS the open question: the gold `*` and the - * gold sentence stand under it and Connect stays disabled, never a silent - * no-op (a stored default or the only usable machine pre-selects honestly and - * clears them; when no machine is enabled the sentence names that). The - * disclosure sentence (the - * `Match exec` note, decision 6) ships verbatim under the destination field, - * before the button, because approving a destination approves running its - * resolution. - * - * The destination is one COMMITTED candidate: a picked row's meaning, or - * nothing. Typed text becomes a candidate through the mirror row the list - * offers (free text accepted, §7), and the commitment releases under any - * later edit, so the field's text and the launch's truth cannot drift. - * The field is the working-directory field's input-plus-panel posture - * (`destination-field.tsx`), not the shared combobox: the list must swap on - * every keystroke, which is exactly what Base UI's held input cannot keep. - * Refusals render on the field they belong to, in red, from - * `ssh-refusal.ts`; the 422's settings list comes from the parsed body, - * because the display message is sliced. Remembering is explicit: every - * launch is already recent server-side; the checkbox PUTs the saved row only - * when checked, sending the alias only for a pick from the machine's own - * config list (§7's alias-is-display rule). +/** Shared SSH launch form for new subshells, workspace additions and splits. + * A picked suggestion retains its canonical destination; editing it submits + * the new text directly. Alternate key machines are an advanced choice. */ const DESTINATION_IDS = { field: "connect-destination", disclosure: "connect-destination-disclosure" }; const MACHINE_IDS = { @@ -56,7 +30,7 @@ const MACHINE_IDS = { /** The disclosure every ssh surface owes before the button (decision 6, verbatim). */ export const SSH_DISCLOSURE_COPY = - "Resolving asks the connecting machine to read its SSH config. A hidden Match exec in that config can run a local command while it resolves."; + "Connecting uses this machine’s SSH configuration, including any local commands configured with Match exec."; /** The destination field's committed state, held as ONE value so a pick and * the text echo that follows it cannot interleave into a stale pair. */ @@ -71,14 +45,25 @@ interface DestinationState { export function ConnectPanel({ initial, + onCreated, + onLeave, + onPendingChange, }: { initial?: { node?: string; destination?: string; keyHome?: string }; + onCreated?: (id: string) => Promise | void; + onLeave?: () => void; + onPendingChange?: (pending: boolean) => void; }): JSX.Element { const navigate = useNavigate(); const { data: nodeData } = useNodes(); const nodes = Array.isArray(nodeData?.nodes) ? nodeData.nodes : null; const { data: ledger, isPending: ledgerPending } = useSshSavedHosts(); const launch = useLaunchSsh(); + const [submitting, setSubmitting] = useState(false); + useEffect(() => { + onPendingChange?.(submitting); + return () => onPendingChange?.(false); + }, [submitting, onPendingChange]); const save = useSaveSshHost(); const setDefault = useSetDefaultNode(); @@ -89,6 +74,9 @@ export function ConnectPanel({ pickId: null, typed: initial?.destination ?? "", }); + const [advanced, setAdvanced] = useState(!!initial?.keyHome); + const [reviewApproval, setReviewApproval] = useState(false); + const [keysApproved, setKeysApproved] = useState(false); const [remember, setRemember] = useState(false); const [refusal, setRefusal] = useState(null); @@ -168,16 +156,14 @@ export function ConnectPanel({ ]; const effectiveKeyHome = keyHome === nodeId ? "" : keyHome; const keyReady = !effectiveKeyHome || keyOptions.some((o) => o.value === effectiveKeyHome && !o.disabled); - const destination = dest.pick?.destination ?? ""; + const destination = dest.pick?.destination ?? dest.typed.trim(); // A committed row's label is the field's text; while that echo stands the // list is UNFILTERED (the whole ledger again), because the echo is display, // not a search. Any keystroke releases the pick, and text becomes query. const pickLabel = dest.pickId !== null ? destinationOptions.find((o) => o.value === dest.pickId)?.label : undefined; const destinationQuery = dest.pick !== null && dest.typed === pickLabel ? "" : dest.typed; - // Typing is only typing here (the field reports keystrokes, never echoes), - // so an edit always releases the commitment: what the field shows is what - // the launch would carry, never a stale pick under new text. + // Editing a suggestion releases its canonical value before another launch. function onDestinationText(text: string): void { setDest({ pick: null, pickId: null, typed: text }); } @@ -193,7 +179,8 @@ export function ConnectPanel({ async function submit(): Promise { // Belt over gate: the button's own disabled condition already holds both // fields, so this can only fire on a render race, never as a silent no-op. - if (nodeId === "" || destination === "" || !keyReady) return; + if (submitting || nodeId === "" || destination === "" || !keyReady) return; + setSubmitting(true); setRefusal(null); try { const created = await launch.mutateAsync({ @@ -210,9 +197,12 @@ export function ConnectPanel({ ...(dest.pick?.aliasToken !== undefined ? { alias: dest.pick.aliasToken } : {}), }); } - void navigate({ to: "/subshells/$id", params: { id: created.subshell.id } }); + if (onCreated) await onCreated(created.subshell.id); + else void navigate({ to: "/subshells/$id", params: { id: created.subshell.id } }); } catch (err) { setRefusal(sshLaunchRefusal(err, selectedNode)); + } finally { + setSubmitting(false); } } @@ -240,15 +230,37 @@ export function ConnectPanel({ .filter((id): id is string => id !== null) .join(" ") || undefined; + if (reviewApproval && refusal?.requestId) + return ( +
+ { + setReviewApproval(false); + setRefusal(null); + setKeysApproved(true); + }} + /> + +
+ ); + return ( -
-
-
- +
+ {keysApproved && ( +

+ Keys approved. Start the SSH subshell when you are ready. +

+ )} + + + )}

- {SSH_DISCLOSURE_COPY} + Enter the machine you want to access, for example deploy@example.com:22. Saved destinations and SSH aliases + appear as suggestions.

-
+ -
+ id !== "" && setNodeId(id)} describedBy={machineDescribedBy} /> +

+ {selectedNode + ? `${selectedNode.name} runs SSH and must be able to reach the destination.` + : "Choose a Subshell machine that can reach the destination. It runs SSH for this terminal."} +

+ {enabledCount === 0 && nodes !== null && ( + + Manage machines and enable SSH + + )} {machineGap && (

{machineGap.text} @@ -297,44 +320,63 @@ export function ConnectPanel({ Use by default )} -

+ -
- - { - setKeyHome(id === "connecting" ? "" : id); - setRefusal(null); - }} - describedBy="connect-key-help" - /> -

- Choose another machine to use keys loaded in its SSH agent. Keys stay on that machine; it must be online and - have SSH enabled. -

- {!keyReady && ( -

- The selected key machine is unavailable. Bring it online with SSH enabled, or choose another. -

- )} - {refusal?.field === "keys" && ( -

- {refusal.text} -

- )} - {refusal?.approval && ( - - Review SSH approval - - )} +
+ +
@@ -348,15 +390,11 @@ export function ConnectPanel({
- -
- - +
); } diff --git a/apps/server/web/src/components/connect/destination-field.tsx b/apps/server/web/src/components/connect/destination-field.tsx index c25e24be2..6170b8eca 100644 --- a/apps/server/web/src/components/connect/destination-field.tsx +++ b/apps/server/web/src/components/connect/destination-field.tsx @@ -54,16 +54,18 @@ export function DestinationField({ // nothing. useEffect(() => { if (!open) return; - function onPointerDown(e: PointerEvent) { + function onOutsideClick(e: MouseEvent) { if (!rootRef.current?.contains(e.target as Node)) setOpen(false); } function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") setOpen(false); } - document.addEventListener("pointerdown", onPointerDown); + // Dismiss after the target receives its click. Removing these in-flow + // suggestions on pointerdown moves the target before pointerup. + document.addEventListener("click", onOutsideClick); document.addEventListener("keydown", onKeyDown); return () => { - document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("click", onOutsideClick); document.removeEventListener("keydown", onKeyDown); }; }, [open]); diff --git a/apps/server/web/src/components/quick-add.tsx b/apps/server/web/src/components/quick-add.tsx index 88dc43256..45b83d33d 100644 --- a/apps/server/web/src/components/quick-add.tsx +++ b/apps/server/web/src/components/quick-add.tsx @@ -1,11 +1,14 @@ import { createContext, type ReactNode, useContext, useMemo, useState } from "react"; import { LaunchSubshellDialog } from "@/components/sidebar/launch-subshell-dialog"; import { NewWorkspaceDialog } from "@/components/sidebar/new-workspace-dialog"; +import type { sshConnectSearch } from "@/lib/ssh-connect-search"; /** Triggers for the rail's quick-add dialogs, callable from anywhere. */ export interface QuickAddApi { /** Open the New-subshell launch dialog. */ openLaunch: () => void; + /** Open the same launch dialog with SSH choices restored. */ + openSshLaunch: (initial?: ReturnType) => void; /** Open the New-workspace dialog. */ openNewWorkspace: () => void; } @@ -32,6 +35,7 @@ export function useQuickAdd(): QuickAddApi { * sidebar, closing the drawer no longer unmounts a dialog that just opened. */ export function QuickAddProvider({ children }: { children: ReactNode }): ReactNode { + const [initialSsh, setInitialSsh] = useState | undefined>(); const [launchOpen, setLaunchOpen] = useState(false); const [workspaceOpen, setWorkspaceOpen] = useState(false); // A fresh object here would be a new context value on every render of this @@ -39,13 +43,23 @@ export function QuickAddProvider({ children }: { children: ReactNode }): ReactNo // desktop bridge re-subscribed its listener on each one. Setters are stable, // so the empty dependency list is honest. const api = useMemo( - () => ({ openLaunch: () => setLaunchOpen(true), openNewWorkspace: () => setWorkspaceOpen(true) }), + () => ({ + openLaunch: () => { + setInitialSsh(undefined); + setLaunchOpen(true); + }, + openSshLaunch: (initial = {}) => { + setInitialSsh(initial); + setLaunchOpen(true); + }, + openNewWorkspace: () => setWorkspaceOpen(true), + }), [], ); return ( {children} - + {launchOpen && } ); diff --git a/apps/server/web/src/components/sidebar/__tests__/launch-subshell-dialog.test.tsx b/apps/server/web/src/components/sidebar/__tests__/launch-subshell-dialog.test.tsx index 0c0912aa2..6a1688c95 100644 --- a/apps/server/web/src/components/sidebar/__tests__/launch-subshell-dialog.test.tsx +++ b/apps/server/web/src/components/sidebar/__tests__/launch-subshell-dialog.test.tsx @@ -18,7 +18,7 @@ import { LaunchSubshellDialog } from "@/components/sidebar/launch-subshell-dialo */ afterEach(cleanup); -async function renderDialog() { +async function renderDialog(initialSsh?: { destination?: string }) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const rootRoute = createRootRoute(); // The dialog rides a route component: RouterProvider renders its `children` @@ -26,7 +26,7 @@ async function renderDialog() { const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", - component: () => {}} />, + component: () => {}} initialSsh={initialSsh} />, }); const subshellRoute = createRoute({ getParentRoute: () => rootRoute, @@ -61,3 +61,11 @@ describe("LaunchSubshellDialog", () => { expect(start.hasAttribute("disabled")).toBe(true); }); }); + +it("focuses the selected SSH type when a link opens the SSH form", async () => { + await renderDialog({ destination: "deploy@host:22" }); + const ssh = screen.getByRole("button", { name: "SSH terminal" }); + expect(ssh.getAttribute("aria-pressed")).toBe("true"); + expect(document.activeElement).toBe(ssh); + expect(screen.getByRole("button", { name: "Agent or terminal" }).getAttribute("aria-pressed")).toBe("false"); +}); diff --git a/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx b/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx index 636dc0216..e42b79f86 100644 --- a/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx +++ b/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx @@ -1,12 +1,14 @@ import { Button } from "@internal/node-admin"; import { useNavigate } from "@tanstack/react-router"; -import { type JSX, useState } from "react"; +import { type JSX, useRef, useState } from "react"; +import { ConnectPanel } from "@/components/connect/connect-panel"; import { canSubmit, emptyNewSubshellForm, type NewSubshellFormValue, } from "@/components/subshell-picker/launch-form-rules"; import { NewSubshellForm } from "@/components/subshell-picker/new-subshell-form"; +import { type SubshellKind, SubshellKindPicker } from "@/components/subshell-picker/subshell-kind-picker"; import { Dialog, DialogContent, @@ -18,6 +20,7 @@ import { } from "@/components/ui/dialog"; import { useCreateSubshell } from "@/hooks/use-create-subshell"; import { createSubshellErrorMessage } from "@/lib/create-subshell-error"; +import type { sshConnectSearch } from "@/lib/ssh-connect-search"; /** * The rail's quick-launch dialog (spec 2026-09-03 sidebar-quickadd §4a), and @@ -29,10 +32,15 @@ import { createSubshellErrorMessage } from "@/lib/create-subshell-error"; export function LaunchSubshellDialog({ open, onOpenChange, + initialSsh, }: { + initialSsh?: ReturnType; open: boolean; onOpenChange: (open: boolean) => void; }): JSX.Element { + const [sshPending, setSshPending] = useState(false); + const [kind, setKind] = useState(initialSsh ? "ssh" : "agent"); + const contentRef = useRef(null); const navigate = useNavigate(); const create = useCreateSubshell(); const [form, setForm] = useState(emptyNewSubshellForm); @@ -41,6 +49,7 @@ export function LaunchSubshellDialog({ function reset() { setForm(emptyNewSubshellForm()); setError(null); + setKind("agent"); } async function submit() { @@ -70,21 +79,45 @@ export function LaunchSubshellDialog({ if (!next) reset(); })} > - + + interaction === "touch" + ? true + : (contentRef.current?.querySelector('[aria-pressed="true"]') ?? true) + } + > New subshell - Launch an agent in a working directory. + Start an agent, a terminal, or an SSH session in a subshell. - onOpenChange(false)} /> - {error &&

{error}

} - - - - + + {kind === "ssh" ? ( + onOpenChange(false)} + onCreated={(id) => { + onOpenChange(false); + reset(); + void navigate({ to: "/subshells/$id", params: { id } }); + }} + /> + ) : ( + <> + onOpenChange(false)} /> + {error &&

{error}

} + + + + + + )}
); diff --git a/apps/server/web/src/components/sidebar/new-workspace-dialog.tsx b/apps/server/web/src/components/sidebar/new-workspace-dialog.tsx index 394a1c8a4..fce368898 100644 --- a/apps/server/web/src/components/sidebar/new-workspace-dialog.tsx +++ b/apps/server/web/src/components/sidebar/new-workspace-dialog.tsx @@ -1,6 +1,7 @@ import { apiPost, Button, errMessage } from "@internal/node-admin"; import { useNavigate } from "@tanstack/react-router"; import { type JSX, useState } from "react"; +import { ConnectPanel } from "@/components/connect/connect-panel"; import { ExistingSubshellList } from "@/components/subshell-picker/existing-subshell-list"; import { canSubmit, @@ -8,6 +9,7 @@ import { type NewSubshellFormValue, } from "@/components/subshell-picker/launch-form-rules"; import { NewSubshellForm } from "@/components/subshell-picker/new-subshell-form"; +import { type SubshellKind, SubshellKindPicker } from "@/components/subshell-picker/subshell-kind-picker"; import { Dialog, DialogContent, @@ -57,6 +59,7 @@ export function NewWorkspaceDialog({ const { data: workspaces } = useWorkspaces(); const create = useCreateSubshell(); + const [kind, setKind] = useState("agent"); const [mode, setMode] = useState("existing"); const [query, setQuery] = useState(""); const [form, setForm] = useState(emptyNewSubshellForm()); @@ -71,6 +74,7 @@ export function NewWorkspaceDialog({ function reset() { setMode("existing"); + setKind("agent"); setQuery(""); setForm(emptyNewSubshellForm()); setSelected([]); @@ -165,7 +169,9 @@ export function NewWorkspaceDialog({ { value: "new", label: "New subshell" }, ]} value={mode} - onChange={setMode} + onChange={(next) => { + if (!launching) setMode(next); + }} /> {createdId ? ( @@ -182,7 +188,21 @@ export function NewWorkspaceDialog({ onToggle={toggle} /> ) : ( - onOpenChange(false)} /> +
+ + {kind === "ssh" ? ( + onOpenChange(false)} + onCreated={(id) => { + setSelected((prev) => [...prev, id]); + setMode("existing"); + }} + /> + ) : ( + onOpenChange(false)} /> + )} +
)} {error &&

{error}

} @@ -200,7 +220,7 @@ export function NewWorkspaceDialog({ - {mode === "new" && ( + {mode === "new" && kind === "agent" && ( diff --git a/apps/server/web/src/components/sidebar/sidebar-nav.ts b/apps/server/web/src/components/sidebar/sidebar-nav.ts index 752fc6a1d..db9872550 100644 --- a/apps/server/web/src/components/sidebar/sidebar-nav.ts +++ b/apps/server/web/src/components/sidebar/sidebar-nav.ts @@ -1,7 +1,6 @@ import { Activity, ArrowUpCircle, - Cable, DatabaseBackup, Fingerprint, KeyRound, @@ -59,10 +58,6 @@ const NAV_ENTRIES: NavEntry[] = [ // Terminal, like the empty subshells box — subshells are terminal harnesses, // not a grid (the grid icon belongs to the tiles/list view toggle). { to: "/", label: "Subshells", icon: TerminalSquare }, - // A primary action, so it sits above the objects it creates, not under - // Settings (spec 2026-10-07 §7). Cable names the link being opened; the - // icons test pins global uniqueness. - { to: "/connect", label: "Connect", icon: Cable }, { to: "/workspaces", label: "Workspaces", icon: LayoutDashboard, short: "Wksp" }, // Personal settings (spec 2026-10-07 §A): the pages this viewer always owns, // grouped the way the admin's Server Settings group is. No gate: every diff --git a/apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx b/apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx index 5516301eb..0f554e9c0 100644 --- a/apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx +++ b/apps/server/web/src/components/ssh/__tests__/pending-approvals.test.tsx @@ -290,7 +290,7 @@ it("approval explains the required retry and provides a return path", async () = await renderWithRoster(); fireEvent.click(screen.getByRole("button", { name: "Approve" })); expect(await screen.findByText(/original connection did not start/)).toBeTruthy(); - expect(screen.getByRole("link", { name: "Return to Connect" })).toBeTruthy(); + expect(screen.getByRole("link", { name: "Return to SSH subshell" })).toBeTruthy(); }); for (const matched of [true, false]) { @@ -299,7 +299,7 @@ for (const matched of [true, false]) { renderScreen({ node: "nodeB", keyHome: "nodeA", destination: "other-alias", requestId: matched ? "req1" : "req2" }); await screen.findAllByRole("checkbox"); fireEvent.click(screen.getByRole("button", { name: "Approve" })); - const link = (await screen.findByRole("link", { name: "Return to Connect" })) as HTMLAnchorElement; + const link = (await screen.findByRole("link", { name: "Return to SSH subshell" })) as HTMLAnchorElement; const search = new URL(link.href, "http://localhost").searchParams; expect(search.get("destination")).toBe(matched ? "other-alias" : REQUEST.destination); }); diff --git a/apps/server/web/src/components/ssh/pending-approvals.tsx b/apps/server/web/src/components/ssh/pending-approvals.tsx index 2b7c8df65..dd2fd7f94 100644 --- a/apps/server/web/src/components/ssh/pending-approvals.tsx +++ b/apps/server/web/src/components/ssh/pending-approvals.tsx @@ -44,11 +44,21 @@ import type { sshConnectSearch } from "@/lib/ssh-connect-search"; * fingerprint out of the grant. A denial needs no confirm: it writes only the * audit row, and a later relaunch simply asks again. */ -export function PendingApprovals({ connection }: { connection?: ReturnType }) { +export function PendingApprovals({ + connection, + onlyRequestId, + onApproved, +}: { + connection?: ReturnType; + onlyRequestId?: string; + onApproved?: () => void; +}) { const [approved, setApproved] = useState(null); const query = useSshGrantRequests(); const view = query.data; - const requests = (view?.requests ?? []).filter((r) => r.status === "pending"); + const requests = (view?.requests ?? []).filter( + (r) => r.status === "pending" && (!onlyRequestId || r.id === onlyRequestId), + ); return ( @@ -62,8 +72,8 @@ export function PendingApprovals({ connection }: { connection?: ReturnType

- Keys approved for {approved.resolvedSelector}. The original connection did not start. Return to Connect - and try again. + Keys approved for {approved.resolvedSelector}. The original connection did not start. Return to the + subshell launcher and try again.

- Return to Connect + Return to SSH subshell
)} @@ -89,7 +99,14 @@ export function PendingApprovals({ connection }: { connection?: ReturnType {requests.map((request) => ( - setApproved(request)} /> + { + if (onApproved) onApproved(); + else setApproved(request); + }} + /> ))}
)} diff --git a/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx b/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx new file mode 100644 index 000000000..2b93a337f --- /dev/null +++ b/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx @@ -0,0 +1,135 @@ +import { afterEach, expect, it } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { NewWorkspaceDialog } from "@/components/sidebar/new-workspace-dialog"; +import { AddSubshellDialog } from "@/components/subshell-picker/add-subshell-dialog"; + +const originalFetch = globalThis.fetch; +afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; +}); + +function api() { + const calls: { path: string; body: unknown }[] = []; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const path = new URL(String(input), "http://localhost").pathname; + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + if (init?.method === "POST") calls.push({ path, body }); + const value = + path === "/api/files/recent" + ? { paths: [], home: null } + : path === "/api/nodes" + ? { + nodes: [ + { + id: "local", + name: "Server", + kind: "local", + status: "online", + canLaunch: true, + canManage: true, + access: "owner", + sshEnabled: true, + maintenance: false, + harnesses: [], + allowedDirs: [], + }, + ], + } + : path === "/api/ssh/saved-hosts" + ? { saved: [], recent: [], defaultNodeId: "local" } + : path === "/api/ssh/aliases" + ? { aliases: [] } + : path === "/api/ssh/launch" + ? { subshell: { id: "ssh-new" } } + : path === "/api/subshells" || path === "/api/presets" + ? [] + : path === "/api/workspaces" + ? init?.method === "POST" + ? { id: "workspace-new" } + : [] + : path.endsWith("/panes") + ? { id: "pane-new" } + : {}; + return new Response(JSON.stringify(value)); + }) as typeof fetch; + return calls; +} + +async function mount(component: () => ReactNode) { + const root = createRootRoute(); + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ getParentRoute: () => root, path: "/", component }), + createRoute({ getParentRoute: () => root, path: "/workspaces/$id", component: () => null }), + ]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + await router.load(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + render( + + + , + ); +} + +async function launchSsh() { + fireEvent.click(await screen.findByRole("button", { name: "New subshell" })); + fireEvent.click(await screen.findByRole("button", { name: "SSH terminal" })); + const field = await screen.findByLabelText("SSH destination"); + fireEvent.change(field, { target: { value: "deploy@example.com:2222" } }); + const start = screen.getByRole("button", { name: "Start SSH subshell" }); + await waitFor(() => expect((start as HTMLButtonElement).disabled).toBe(false)); + fireEvent.click(start); +} + +it("adds the SSH result to the current split and blocks duplicate launches while attaching", async () => { + const calls = api(); + const adds: string[][] = []; + let finish!: () => void; + let closed = false; + await mount(() => ( + { + closed = true; + }} + onAdd={async (id, direction) => { + adds.push([id, direction]); + await new Promise((resolve) => { + finish = resolve; + }); + }} + /> + )); + await launchSsh(); + await waitFor(() => expect(adds).toEqual([["ssh-new", "right"]])); + const start = screen.getByRole("button", { name: "Connecting…" }); + expect((start as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(start); + expect(calls.filter((c) => c.path === "/api/ssh/launch")).toHaveLength(1); + finish(); + await waitFor(() => expect(closed).toBe(true)); +}); + +it("keeps a newly launched SSH subshell selected when creating a workspace", async () => { + const calls = api(); + await mount(() => {}} />); + await launchSsh(); + await screen.findByText("1 subshell to add"); + fireEvent.click(screen.getByRole("button", { name: /Create workspace/ })); + await waitFor(() => + expect(calls).toContainEqual({ path: "/api/workspaces/workspace-new/panes", body: { subshellId: "ssh-new" } }), + ); +}); diff --git a/apps/server/web/src/components/subshell-picker/add-subshell-dialog.tsx b/apps/server/web/src/components/subshell-picker/add-subshell-dialog.tsx index 622c72dc7..b2b798046 100644 --- a/apps/server/web/src/components/subshell-picker/add-subshell-dialog.tsx +++ b/apps/server/web/src/components/subshell-picker/add-subshell-dialog.tsx @@ -1,5 +1,6 @@ import { Button, errMessage } from "@internal/node-admin"; import { type JSX, useState } from "react"; +import { ConnectPanel } from "@/components/connect/connect-panel"; import { DirectionSelect } from "@/components/subshell-picker/direction-select"; import { ExistingSubshellList } from "@/components/subshell-picker/existing-subshell-list"; import { @@ -8,6 +9,7 @@ import { type NewSubshellFormValue, } from "@/components/subshell-picker/launch-form-rules"; import { NewSubshellForm } from "@/components/subshell-picker/new-subshell-form"; +import { type SubshellKind, SubshellKindPicker } from "@/components/subshell-picker/subshell-kind-picker"; import { Dialog, DialogContent, @@ -74,6 +76,7 @@ export function AddSubshellDialog({ const { data: subshells, isError: subshellsFailed, isLoading: subshellsLoading } = useSubshellsList(); const create = useCreateSubshell(); + const [kind, setKind] = useState("agent"); const [mode, setMode] = useState("existing"); const [direction, setDirection] = useState("right"); const [query, setQuery] = useState(""); @@ -88,6 +91,7 @@ export function AddSubshellDialog({ /** Resets everything the next open should not inherit. */ function reset() { setMode("existing"); + setKind("agent"); setQuery(""); setForm({ ...emptyNewSubshellForm(), ...initialForm }); setBusyId(null); @@ -170,7 +174,9 @@ export function AddSubshellDialog({ { value: "new", label: "New subshell" }, ]} value={mode} - onChange={setMode} + onChange={(next) => { + if (!creating) setMode(next); + }} /> @@ -183,7 +189,7 @@ export function AddSubshellDialog({ // remote pick is made; an unmade pick — "" — is the local default). // Display filtering only: the list is already visibility-filtered // server-side and this never substitutes for authz. - nodeId={form.nodeId || "local"} + nodeId={kind === "ssh" ? undefined : form.nodeId || "local"} query={query} onQueryChange={setQuery} loadFailed={subshellsFailed} @@ -192,12 +198,36 @@ export function AddSubshellDialog({ busyId={busyId} /> ) : ( - onOpenChange(false)} /> +
+ + {kind === "ssh" ? ( + onOpenChange(false)} + onCreated={async (id) => { + try { + await onAdd(id, direction); + onOpenChange(false); + reset(); + } catch (err) { + setError( + `SSH subshell was created but could not be added (${errMessage(err, "unknown error")}). Add it from the list instead.`, + ); + setForm({ ...form, nodeId: "" }); + setMode("existing"); + } + }} + /> + ) : ( + onOpenChange(false)} /> + )} +
)} {error &&

{error}

} - {mode === "new" && ( + {mode === "new" && kind === "agent" && (