diff --git a/.changeset/quick-machine-launch.md b/.changeset/quick-machine-launch.md new file mode 100644 index 000000000..308d8b7bb --- /dev/null +++ b/.changeset/quick-machine-launch.md @@ -0,0 +1,7 @@ +--- +"@internal/server": patch +--- + +Add a + menu beside sidebar machine groups to start a terminal with that machine's recent directory or open the new-subshell dialog with the node selected. + +Keep the working-directory default when reopening the launch dialog, and close the directory picker after choosing a folder, Recent path, or Favorite without reopening it when focus returns to the input. diff --git a/apps/server/web/docs/launch-form-picker.md b/apps/server/web/docs/launch-form-picker.md index c08dfbda7..6b73e9543 100644 --- a/apps/server/web/docs/launch-form-picker.md +++ b/apps/server/web/docs/launch-form-picker.md @@ -30,6 +30,14 @@ byte-identical; a node star is 404'd for invisible nodes (the no-oracle rule `/recent` and `/explore` apply) and 400'd for relative paths: the plane never resolves a node path against this host's filesystem. +**Selecting a directory closes the inline picker.** Folder, Recent, and +Favorite rows commit their path and restore focus to the input without +reopening the panel. Clicking the input again continues browsing from the +selected path. Going up a level and Start over keep the picker open; Escape +closes the picker before the surrounding dialog handles it. Only an Escape +addressed inside the picker is claimed, so a popup the person tabbed to while +the panel sat open still receives its own. + **The launch form asks nothing it cannot answer.** `new-subshell-form.tsx` filters its node options on the server's own `canLaunch` (never a re-derived rule), hides the Machine field when the sole target is the control-plane host (a single AGENT node keeps it, because once a second machine exists the answer diff --git a/apps/server/web/docs/sidebar.md b/apps/server/web/docs/sidebar.md index ab3b5b6d9..b6dc87dc9 100644 --- a/apps/server/web/docs/sidebar.md +++ b/apps/server/web/docs/sidebar.md @@ -29,6 +29,14 @@ it; modified clicks retain the real subshell link. The actions menu beside group keeps Add subshell and Subshell dashboard. The dashboard is an explicit route change, not a separate section. +Machine headings in both lists have a + menu. New terminal launches the +terminal plugin without a preset or prompt, using that node's newest recent +path or its home directory. New subshell opens the root-owned launch dialog +with that node selected; the prior-launch default must not replace an explicit +selection, including `local`. Both actions keep the normal workspace filing +and launch gates. Unknown nodes and the shared-session group have no launch +menu because those headings don't establish access to a machine. + `WorkspaceSidebarDrag` coordinates the two MIME-scoped drop areas. A drag exposes a centered Add to workspace or Remove from workspace overlay, without moving the lists. Its preview clones just the row, never the subagent tree. 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 7b0cf3810..0292e991e 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 @@ -95,6 +95,7 @@ let restoreQuickAdd: () => void; beforeEach(() => { const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openLaunchOnNode: () => {}, openSshLaunch: () => {}, openNewWorkspace: () => {}, }); 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 c25cb89fc..5fccc629f 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 @@ -125,6 +125,7 @@ describe("the panel and its bottom section bar", () => { it("opens the workspace page even when the remembered landing route would redirect", async () => { const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch() {}, + openLaunchOnNode() {}, openSshLaunch() {}, openNewWorkspace() {}, }); @@ -274,6 +275,7 @@ describe("the panel and its bottom section bar", () => { it("shows only the chosen workspace and recovers a failed workspace read", async () => { const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch() {}, + openLaunchOnNode() {}, openSshLaunch() {}, openNewWorkspace() {}, }); 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 652410f1b..b2ba5e7c3 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 @@ -237,6 +237,7 @@ const withRail = async ( const restoreFetch = stubFetch(subshells, state); const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openLaunchOnNode: () => {}, openSshLaunch: () => {}, openNewWorkspace: () => {}, }); @@ -673,6 +674,7 @@ describe("the Workspaces tree (spec 2026-10-17)", () => { const quickAddSpy = () => spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openLaunchOnNode: () => {}, openSshLaunch: () => {}, openNewWorkspace: () => {}, }); diff --git a/apps/server/web/src/components/__tests__/directory-picker-input.test.tsx b/apps/server/web/src/components/__tests__/directory-picker-input.test.tsx index 9b00e9fe8..0d90e5778 100644 --- a/apps/server/web/src/components/__tests__/directory-picker-input.test.tsx +++ b/apps/server/web/src/components/__tests__/directory-picker-input.test.tsx @@ -444,31 +444,28 @@ describe("DirectoryPickerInput", () => { } }); - it("clicking a folder sets the input to it and descends without closing the panel", async () => { + it("selecting a folder closes the panel and an explicit click continues browsing", async () => { const picks: string[] = []; const { requested, restore } = mockExplore({ "/home": exploreBody("/home", ["projects"]), "/home/projects": exploreBody("/home/projects", ["subshell"]), - "/home/projects/subshell": exploreBody("/home/projects/subshell", ["src"]), }); try { - const field = renderField({ value: "/home", onChange: (p) => picks.push(p) }); - field.open(); - // Each click is one action: the input gets the folder AND the panel - // descends into it. Two levels deep, no extra button involved. - fireEvent.click(await screen.findByText("projects")); - fireEvent.click(await screen.findByText("subshell")); - await screen.findByText("src"); - // The listing state uses the same fixed-height body as the loading - // state above — scrolls internally instead of growing. - expect(field.container.querySelector(".overflow-y-auto")?.className).toContain("h-56"); - expect(picks).toEqual(["/home/projects", "/home/projects/subshell"]); - // The first click's typed-sync timer must not yank the panel back to - // /home/projects after the second click has already descended. - await new Promise((r) => setTimeout(r, 300)); - expect(screen.queryByText("subshell")).toBeNull(); - // "/home" is fetched once at focus; the descend fetches the others. - expect(requested.filter((p) => p === "/home/projects")).toHaveLength(1); + renderField({ value: "/home", onChange: (p) => picks.push(p) }); + const input = screen.getByRole("textbox") as HTMLInputElement; + fireEvent.focus(input); + const folder = await screen.findByTitle("Select projects"); + folder.focus(); + fireEvent.click(folder); + expect(input.value).toBe("/home/projects"); + expect(document.activeElement).toBe(input); + expect(screen.queryByRole("button", { name: ".." })).toBeNull(); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(screen.queryByTitle("Select subshell")).toBeNull(); + expect(requested).toEqual(["/home"]); + fireEvent.click(input); + await screen.findByTitle("Select subshell"); + expect(picks).toEqual(["/home/projects"]); } finally { restore(); } @@ -486,15 +483,15 @@ describe("DirectoryPickerInput", () => { try { renderField({ value: "/tmp", onChange: (p) => picks.push(p) }); fireEvent.focus(screen.getByRole("textbox")); - await screen.findByTitle("Open dir-0"); - const rows = screen.getAllByTitle(/^Open dir-/); + await screen.findByTitle("Select dir-0"); + const rows = screen.getAllByTitle(/^Select dir-/); // Bounded well below the entry count, and the last directory is not // there: this is what used to be 5,000 committed rows. expect(rows.length).toBeLessThan(150); - expect(screen.queryByTitle("Open dir-4999") === null).toBe(true); + expect(screen.queryByTitle("Select dir-4999") === null).toBe(true); // A row in the window behaves exactly like the old ordinary rows: - // one click selects the path and the panel descends. - fireEvent.click(screen.getByTitle("Open dir-0")); + // one click selects the path and closes the panel. + fireEvent.click(screen.getByTitle("Select dir-0")); expect(picks).toEqual(["/tmp/dir-0"]); expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("/tmp/dir-0"); } finally { @@ -509,15 +506,15 @@ describe("DirectoryPickerInput", () => { try { renderField({ value: "/tmp" }); fireEvent.focus(screen.getByRole("textbox")); - await screen.findByTitle("Open alpha"); - expect(screen.getAllByTitle(/^Open /)).toHaveLength(3); - expect(screen.getByTitle("Open gamma")).toBeDefined(); + await screen.findByTitle("Select alpha"); + expect(screen.getAllByTitle(/^Select /)).toHaveLength(3); + expect(screen.getByTitle("Select gamma")).toBeDefined(); } finally { restore(); } }); - it("a recent entry selects and browses the panel to it — closing is not part of it", async () => { + it("a recent entry selects its path and closes the panel", async () => { const picks: string[] = []; const { requested, restore } = mockExplore({ "/tmp": exploreBody("/tmp", [], [{ path: "/srv/app", label: "app" }]), @@ -528,10 +525,10 @@ describe("DirectoryPickerInput", () => { fireEvent.focus(screen.getByRole("textbox")); fireEvent.click(await screen.findByText("app")); expect(picks).toEqual(["/srv/app"]); - // The panel followed the selection like any other folder click: its - // listing now shows /srv/app, and the shortcut stays reachable. - await screen.findByText("src"); - expect(requested).toContain("/srv/app"); + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("/srv/app"); + expect(screen.queryByText("src")).toBeNull(); + expect(screen.queryByText("Recent")).toBeNull(); + expect(requested).toEqual(["/tmp"]); } finally { restore(); } @@ -560,8 +557,9 @@ describe("DirectoryPickerInput", () => { expect(screen.getByRole("button", { name: "Unfavorite /srv/starred" })).toBeDefined(); fireEvent.click(screen.getByText("starred repo")); expect(picks).toEqual(["/srv/starred"]); - // Like every other row: select AND browse, panel stays open. - await screen.findByText("nested"); + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("/srv/starred"); + expect(screen.queryByText("Favorites")).toBeNull(); + expect(screen.queryByText("nested")).toBeNull(); } finally { restore(); } @@ -682,6 +680,82 @@ describe("DirectoryPickerInput", () => { } }); + it("returns focus from a dismissed picker row without reopening, and a deliberate click opens it again", async () => { + const { restore } = mockExplore({ "/tmp": exploreBody("/tmp", ["child"]) }); + try { + renderField({ value: "/tmp" }); + const input = screen.getByRole("textbox"); + fireEvent.focus(input); + const row = await screen.findByTitle("Select child"); + row.focus(); + fireEvent.keyDown(row, { key: "Escape" }); + await waitFor(() => expect(screen.queryByTitle("Select child")).toBeNull()); + expect(document.activeElement).toBe(input); + fireEvent.click(input); + await screen.findByTitle("Select child"); + } finally { + restore(); + } + }); + + /** + * The picker's Escape handler runs on the document in the CAPTURE phase, + * upstream of every other keydown listener, including a dialog's own + * dismissal. Stopping the event there is what keeps one Escape from closing + * the panel and the launch dialog at once. Stopping EVERY Escape is a + * different bug: a popup the person tabbed to while the panel sat open + * never receives its Escape. So only an Escape whose target lives inside the + * picker gets claimed; any other one closes the panel and travels on. + */ + it("claims only the Escape addressed to the panel", async () => { + const { restore } = mockExplore({ "/tmp": exploreBody("/tmp", ["child"]) }); + // Stands in for a dialog's dismiss listener: same document/bubble seat + // Base UI's useDismiss occupies. + let escapesReachedBubble = 0; + const watcher = (e: KeyboardEvent) => { + if (e.key === "Escape") escapesReachedBubble += 1; + }; + document.addEventListener("keydown", watcher); + try { + renderField({ value: "/tmp" }); + const input = screen.getByRole("textbox"); + fireEvent.focus(input); + await screen.findByTitle("Select child"); + // Inside the panel: claimed, the bubble seat never sees it. + fireEvent.keyDown(input, { key: "Escape" }); + await waitFor(() => expect(screen.queryByTitle("Select child") === null).toBe(true)); + expect(escapesReachedBubble).toBe(0); + // Outside the panel (the launch dialog holds focus): the panel still + // closes, and the event travels on to whoever else is listening. + fireEvent.click(input); + await screen.findByTitle("Select child"); + fireEvent.keyDown(document.body, { key: "Escape" }); + await waitFor(() => expect(screen.queryByTitle("Select child") === null).toBe(true)); + expect(escapesReachedBubble).toBe(1); + } finally { + document.removeEventListener("keydown", watcher); + restore(); + } + }); + + it("keeps an empty directory choice closed when focus returns to the input", async () => { + const { restore } = mockExplore({ "/tmp": exploreBody("/tmp", []) }); + try { + renderField({ value: "/tmp", clearOption: "Decide at launch" }); + const input = screen.getByRole("textbox") as HTMLInputElement; + fireEvent.focus(input); + const clear = await screen.findByRole("button", { name: "Decide at launch" }); + clear.focus(); + fireEvent.click(clear); + await waitFor(() => expect(screen.queryByRole("button", { name: "Decide at launch" })).toBeNull()); + expect(document.activeElement).toBe(input); + expect(input.value).toBe(""); + expect(screen.queryByRole("button", { name: ".." })).toBeNull(); + } finally { + restore(); + } + }); + /** * macOS asks per protected folder the first time one is listed, and a * decline makes the read throw forever after. The server flags the LISTING @@ -918,7 +992,7 @@ describe("DirectoryPickerInput", () => { try { renderField({ value: "/big" }); fireEvent.focus(screen.getByRole("textbox")); - await screen.findByTitle("Open cc"); + await screen.findByTitle("Select cc"); // Exactly one follow-up request, and it names the cursor verbatim: // percent-encoded on the wire, decoding back to the same value. const followups = urls.filter((u) => u.includes("cursor=")); @@ -929,11 +1003,11 @@ describe("DirectoryPickerInput", () => { expect(second.searchParams.get("cursor")).toBe("c 1"); expect(second.search).toContain("cursor=c%201"); // One continuous listing: page 2's folders follow page 1's. - expect(screen.getAllByTitle(/^Open /).map((n) => n.getAttribute("title"))).toEqual([ - "Open aa", - "Open bb", - "Open cc", - "Open dd", + expect(screen.getAllByTitle(/^Select /).map((n) => n.getAttribute("title"))).toEqual([ + "Select aa", + "Select bb", + "Select cc", + "Select dd", ]); // Page 2 names no cursor, so the walk stops there: two requests total. expect(urls).toHaveLength(2); @@ -964,7 +1038,7 @@ describe("DirectoryPickerInput", () => { try { renderField({ value: "/mixed" }); fireEvent.focus(screen.getByRole("textbox")); - await screen.findByTitle("Open work"); + await screen.findByTitle("Select work"); expect(urls.filter((u) => u.includes("cursor=n2"))).toHaveLength(1); } finally { restore(); @@ -995,7 +1069,7 @@ describe("DirectoryPickerInput", () => { await waitFor(() => expect(urls).toHaveLength(1)); await new Promise((r) => setTimeout(r, 100)); expect(urls).toHaveLength(1); - expect(screen.queryByTitle("Open late-dir") === null).toBe(true); + expect(screen.queryByTitle("Select late-dir") === null).toBe(true); } finally { restore(); } @@ -1006,7 +1080,7 @@ describe("DirectoryPickerInput", () => { try { renderField({ value: "/small" }); fireEvent.focus(screen.getByRole("textbox")); - await screen.findByTitle("Open two"); + await screen.findByTitle("Select two"); // Both rows fit the window, the same state that walks a NON-exhausted // listing; with no cursor to spend the walk must not ask again. await new Promise((r) => setTimeout(r, 50)); @@ -1023,7 +1097,7 @@ describe("DirectoryPickerInput", () => { try { renderField({ value: "/capped" }); fireEvent.focus(screen.getByRole("textbox")); - await screen.findByTitle("Open a1"); + await screen.findByTitle("Select a1"); expect(screen.getAllByText("The listing stops at 1,000 folders, so some may be missing.")).toHaveLength(1); } finally { restore(); @@ -1035,7 +1109,7 @@ describe("DirectoryPickerInput", () => { try { renderField({ value: "/full" }); fireEvent.focus(screen.getByRole("textbox")); - await screen.findByTitle("Open one"); + await screen.findByTitle("Select one"); expect(screen.queryByText("The listing stops at 1,000 folders, so some may be missing.") === null).toBe(true); } finally { restore(); @@ -1056,7 +1130,7 @@ describe("DirectoryPickerInput", () => { renderField({ value: "/deep" }); fireEvent.focus(screen.getByRole("textbox")); // The chain walks on to exhaustion: 3 pages, one set of sections. - await screen.findByTitle("Open cc"); + await screen.findByTitle("Select cc"); expect(urls).toHaveLength(3); expect(screen.getAllByText("app")).toHaveLength(1); expect(screen.getAllByText("/srv/keep")).toHaveLength(1); @@ -1077,9 +1151,9 @@ describe("DirectoryPickerInput", () => { try { renderField({ value: "/shift" }); fireEvent.focus(screen.getByRole("textbox")); - await screen.findByTitle("Open b"); - expect(screen.getAllByTitle("Open a")).toHaveLength(1); - expect(screen.getAllByTitle(/^Open /).map((n) => n.getAttribute("title"))).toEqual(["Open a", "Open b"]); + await screen.findByTitle("Select b"); + expect(screen.getAllByTitle("Select a")).toHaveLength(1); + expect(screen.getAllByTitle(/^Select /).map((n) => n.getAttribute("title"))).toEqual(["Select a", "Select b"]); expect(urls).toHaveLength(2); } finally { restore(); @@ -1111,11 +1185,11 @@ describe("DirectoryPickerInput", () => { try { renderField({ value: "/big" }); fireEvent.focus(screen.getByRole("textbox")); - await screen.findByTitle("Open aa"); + await screen.findByTitle("Select aa"); await waitFor(() => expect(urls.filter((u) => u.includes("cursor=boom"))).toHaveLength(1)); // The listing is still the panel: no full-panel error over it. expect(screen.queryByText("Couldn't browse this path.")).toBeNull(); - expect(screen.getByTitle("Open bb")).toBeDefined(); + expect(screen.getByTitle("Select bb")).toBeDefined(); // Across a settled wait: exactly page 1 plus the one failed page. await new Promise((r) => setTimeout(r, 150)); expect(urls).toHaveLength(2); 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 fedc303cc..a800e7d91 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 @@ -104,6 +104,7 @@ function stubFetch() { // none of it under test. const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openLaunchOnNode: () => {}, openSshLaunch: () => {}, openNewWorkspace: () => {}, }); diff --git a/apps/server/web/src/components/directory-picker-input.tsx b/apps/server/web/src/components/directory-picker-input.tsx index 3ce808057..3ea6310f7 100644 --- a/apps/server/web/src/components/directory-picker-input.tsx +++ b/apps/server/web/src/components/directory-picker-input.tsx @@ -4,7 +4,7 @@ import { type InfiniteData, useInfiniteQuery, useMutation, useQueryClient } from import { useVirtualizer } from "@tanstack/react-virtual"; import { ArrowUp, ChevronRight, FolderOpen, Star } from "lucide-react"; import type { JSX } from "react"; -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { PermissionNotice } from "@/components/desktop/permission-notice"; import { desktopPlatform, isServerDesktop } from "@/lib/desktop"; import { patchExploreFavorite, shouldFetchNextPage } from "@/lib/explore-pagination"; @@ -36,10 +36,10 @@ export function blockedByName(deployment: ServerDeployment | undefined): string * new-subshell form's working-directory field. * * Fully controlled: `value`/`onChange` come from the parent, and the panel - * always shows the folder the input names. Every row click — folder, Recent, - * or Favorites — selects the path AND browses the panel to it; nothing but - * an outside click or Escape closes it. Typing in the input moves the open - * panel to the typed path, and a path that does not exist says so in place + * always shows the folder the input names. Clicking a folder, Recent, or + * Favorite row selects the path and closes the panel. The input regains focus + * without reopening it; clicking the input again continues browsing. Typing + * moves the open panel to the typed path, and a path that doesn't exist says so * with a Start-over button that clears the field and returns to home. * * Every row carries a star (revealed on hover, solid for favorites) that @@ -93,6 +93,19 @@ export function DirectoryPickerInput({ const [pickerPath, setPickerPath] = useState(""); const [pickerOpen, setPickerOpen] = useState(false); const rootRef = useRef(null); + const inputRef = useRef(null); + const restoringFocusRef = useRef(false); + + // Move focus before removing a focused picker row. Letting the dialog + // repair focus afterwards can focus the input and open the panel again. + const closePicker = useCallback(() => { + if (document.activeElement !== inputRef.current && rootRef.current?.contains(document.activeElement)) { + restoringFocusRef.current = true; + inputRef.current?.focus({ preventScroll: true }); + restoringFocusRef.current = false; + } + setPickerOpen(false); + }, []); // The `h-56` listing body, held as state rather than a ref, and it has to // be that way: the virtualizer inside `DirRows` can only attach its scroll // listeners once the element actually exists, and on the mount commit the @@ -142,18 +155,25 @@ export function DirectoryPickerInput({ useEffect(() => { if (!pickerOpen) return; function onPointerDown(e: PointerEvent) { - if (!rootRef.current?.contains(e.target as Node)) setPickerOpen(false); + if (!rootRef.current?.contains(e.target as Node)) closePicker(); } function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") setPickerOpen(false); + if (e.key === "Escape") { + // Claim only the Escape that belongs to the panel (a row or the + // input has focus) — stopping another one would swallow the dismiss + // of a popup the user tabbed to while the panel sat open. The panel + // closes either way. + if (rootRef.current?.contains(e.target as Node)) e.stopPropagation(); + closePicker(); + } } document.addEventListener("pointerdown", onPointerDown); - document.addEventListener("keydown", onKeyDown); + document.addEventListener("keydown", onKeyDown, true); return () => { document.removeEventListener("pointerdown", onPointerDown); - document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("keydown", onKeyDown, true); }; - }, [pickerOpen]); + }, [pickerOpen, closePicker]); // The node rides the query key too: the same path on a different machine // is a different listing, and switching nodes mid-open must refetch. @@ -296,22 +316,27 @@ export function DirectoryPickerInput({ } function openPicker() { + if (restoringFocusRef.current) return; setPickerPath(value || "~"); setPickerOpen(true); } /** - * Selects `path` — it becomes the input's value — and browses into it, - * keeping the panel open. Claiming the change in `lastTypedRef` first is - * what keeps this self-inflicted `value` change from being mistaken for - * typing by the sync effect above. + * Browses to `path` without letting the typing debounce undo navigation. + * The parent-directory controls keep the panel open so browsing can continue. */ - function pick(path: string) { + function browse(path: string) { lastTypedRef.current = path; onChange(path); setPickerPath(path); } + /** Commits a directory choice and closes before focus can reopen the panel. */ + function pick(path: string) { + browse(path); + closePicker(); + } + /** * Gives up on a path that does not exist: the input is cleared and the * panel restarts at the home directory, so the next move is a fresh browse @@ -330,16 +355,18 @@ export function DirectoryPickerInput({ function clearChoice() { lastTypedRef.current = ""; onChange(""); - setPickerOpen(false); + closePicker(); } return (
onChange(e.target.value)} onFocus={openPicker} + onClick={openPicker} placeholder={placeholder} /> @@ -451,7 +478,7 @@ export function DirectoryPickerInput({ {explore.parent && ( + ); +} + +async function setup({ + node = machine("remote"), + paths = [{ path: "/project", label: null }], + lockdown = false, + extraTrigger, +}: { + node?: Node; + paths?: { path: string; label: null }[]; + lockdown?: boolean; + extraTrigger?: "empty-state"; +} = {}) { + const creates: Record[] = []; + const recentNodes: (string | null)[] = []; + setCurrentWorkspace("workspace"); + setFetchRouter(async (input, init) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/files/recent") { + recentNodes.push(url.searchParams.get("node")); + return Response.json({ paths, home: `/${url.searchParams.get("node")}/home` }); + } + if (url.pathname === "/api/subshells" && init?.method === "POST") { + creates.push(JSON.parse(String(init.body))); + return Response.json({ id: "created" }); + } + if (url.pathname === "/api/subshells") + return Response.json([ + { + id: "previous", + nodeId: "remote", + harnessId: "claude-code", + workingDir: "/remote/previous", + createdAt: "2026-10-25T00:00:00Z", + }, + ]); + if (url.pathname === "/api/nodes") return Response.json({ nodes: [machine("local"), machine("remote")] }); + if (url.pathname === "/api/plugins") + return Response.json({ + plugins: [ + { id: "terminal", name: "Terminal", type: "terminal", installed: true, enabled: true }, + { id: "claude-code", name: "Claude Code", type: "agent-harness", installed: true, enabled: true }, + ], + }); + if (url.pathname === "/api/settings/public") return Response.json({ lockdown }); + return Response.json([]); + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const root = createRootRoute({ + component: () => ( + + {extraTrigger === "empty-state" && } + + + ), + }); + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ getParentRoute: () => root, path: "/", component: () => null }), + createRoute({ getParentRoute: () => root, path: "/workspaces/$id", component: () => null }), + ]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + await router.load(); + render( + + + + + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + }); + return { creates, recentNodes, router }; +} + +it("starts a terminal immediately on the named node and files it into the current workspace", async () => { + const { creates, recentNodes, router } = await setup(); + fireEvent.click(screen.getByRole("button", { name: "New subshell on Laptop" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "New terminal" })); + await waitFor(() => expect(creates).toHaveLength(1)); + expect(creates[0]).toMatchObject({ + nodeId: "remote", + harnessId: "terminal", + workingDir: "/project", + workspaceId: "workspace", + }); + expect(creates[0].presetId).toBeUndefined(); + expect(creates[0].prompt).toBeUndefined(); + expect(recentNodes).toEqual(["remote"]); + await waitFor(() => expect(router.state.location.pathname).toBe("/workspaces/workspace")); +}); + +it("uses the selected node's home when it has no recent directories", async () => { + const { creates } = await setup({ paths: [] }); + fireEvent.click(screen.getByRole("button", { name: "New subshell on Laptop" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "New terminal" })); + await waitFor(() => expect(creates[0]?.workingDir).toBe("/remote/home")); +}); + +it("keeps an explicitly selected server instead of copying the last launch's remote node", async () => { + const { creates } = await setup({ node: machine("local"), paths: [] }); + fireEvent.click(screen.getByRole("button", { name: "New subshell on Server" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "New subshell" })); + await screen.findByRole("dialog"); + await waitFor(() => + expect((screen.getByRole("combobox", { name: "Node" }) as HTMLInputElement).value).toContain("Server"), + ); + await waitFor(() => + expect((screen.getByLabelText("Working directory") as HTMLInputElement).value).toBe("/local/home"), + ); + expect(creates).toHaveLength(0); +}); + +it("keeps defaults when the empty-state CTA hands the launch trigger its own click event", async () => { + const { creates, recentNodes } = await setup({ extraTrigger: "empty-state" }); + fireEvent.click(screen.getByRole("button", { name: "Create your first subshell" })); + await screen.findByRole("dialog"); + // The form opens on the newest prior launch, not on the event: the node + // pick reads Laptop, the directory is the prior pane's own, and no + // [object Object] ever names a machine to /recent. + await waitFor(() => + expect((screen.getByRole("combobox", { name: "Node" }) as HTMLInputElement).value).toContain("Laptop"), + ); + await waitFor(() => + expect((screen.getByLabelText("Working directory") as HTMLInputElement).value).toBe("/remote/previous"), + ); + expect(recentNodes).not.toContain("[object Object]"); + expect(creates).toHaveLength(0); +}); + +for (const [label, overrides] of [ + ["offline", { status: "offline" }], + ["maintenance", { maintenance: true, canLaunch: false }], + ["permission", { canLaunch: false }], +] as const) { + it(`blocks launch when the machine is unavailable: ${label}`, async () => { + const { creates } = await setup({ node: machine("remote", overrides) }); + const button = screen.getByRole("button", { name: "New subshell on Laptop" }); + expect(button.getAttribute("aria-disabled") === "true" || (button as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(button); + expect(screen.queryByRole("menuitem")).toBeNull(); + expect(creates).toHaveLength(0); + }); +} + +it("blocks launches during lockdown", async () => { + await setup({ lockdown: true }); + const button = screen.getByRole("button", { name: "New subshell on Laptop" }); + expect(button.getAttribute("aria-disabled") === "true" || (button as HTMLButtonElement).disabled).toBe(true); +}); + +it("keeps the directory default when reopening a node launch with cached paths in Strict Mode", async () => { + await setup({ node: machine("local"), paths: [] }); + for (let attempt = 0; attempt < 2; attempt++) { + fireEvent.click(screen.getByRole("button", { name: "New subshell on Server" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "New subshell" })); + await waitFor(() => + expect((screen.getByLabelText("Working directory") as HTMLInputElement).value).toBe("/local/home"), + ); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + } +}); diff --git a/apps/server/web/src/components/sidebar/active-workspace-section.tsx b/apps/server/web/src/components/sidebar/active-workspace-section.tsx index 0d944843c..af13ccf09 100644 --- a/apps/server/web/src/components/sidebar/active-workspace-section.tsx +++ b/apps/server/web/src/components/sidebar/active-workspace-section.tsx @@ -240,6 +240,7 @@ export function ActiveWorkspaceSection({ onQuickAdd, headerEnd }: { headerEnd?: {active && ( {sections.map((section) => (
- {section.label && {section.label}} + {section.label && ( + node.id === section.key)} + onAction={onAction} + /> + ) : undefined + } + > + {section.label} + + )} {section.subshells.map((subshell) => ( ; open: boolean; onOpenChange: (open: boolean) => void; @@ -54,7 +56,10 @@ export function LaunchSubshellDialog({ const navigate = useNavigate(); const queryClient = useQueryClient(); const create = useCreateSubshell(); - const [form, setForm] = useState(emptyNewSubshellForm); + const [form, setForm] = useState(() => ({ + ...emptyNewSubshellForm(), + ...(initialNodeId ? { nodeId: initialNodeId } : {}), + })); const [error, setError] = useState(null); function reset() { @@ -156,7 +161,12 @@ export function LaunchSubshellDialog({ /> ) : ( <> - + {error &&

{error}

}