From 18881ba64ac282b277d6e0e8889b35ef19419d67 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Mon, 26 Oct 2026 01:00:28 -0700 Subject: [PATCH 1/3] feat(web): add machine quick-launch menus to the sidebar --- .changeset/quick-machine-launch.md | 7 + apps/server/web/docs/launch-form-picker.md | 6 + apps/server/web/docs/sidebar.md | 8 + .../__tests__/directory-picker-input.test.tsx | 136 ++++++++----- .../components/__tests__/quick-add.test.tsx | 2 +- .../src/components/directory-picker-input.tsx | 64 ++++-- apps/server/web/src/components/quick-add.tsx | 11 +- .../__tests__/node-launch-menu.test.tsx | 182 ++++++++++++++++++ .../sidebar/active-workspace-section.tsx | 1 + .../sidebar/all-subshells-section.tsx | 16 +- .../sidebar/launch-subshell-dialog.tsx | 14 +- .../components/sidebar/node-launch-menu.tsx | 104 ++++++++++ .../sidebar/sidebar-group-heading.tsx | 17 +- .../sidebar/workspace-pane-groups.tsx | 11 +- .../components/sidebar/workspaces-section.tsx | 9 +- .../subshell-picker/new-subshell-form.tsx | 4 + .../use-launch-form-defaults.ts | 16 +- apps/server/web/src/routes/subshells.tsx | 2 +- 18 files changed, 518 insertions(+), 92 deletions(-) create mode 100644 .changeset/quick-machine-launch.md create mode 100644 apps/server/web/src/components/sidebar/__tests__/node-launch-menu.test.tsx create mode 100644 apps/server/web/src/components/sidebar/node-launch-menu.tsx 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..f0fc3f88a 100644 --- a/apps/server/web/docs/launch-form-picker.md +++ b/apps/server/web/docs/launch-form-picker.md @@ -30,6 +30,12 @@ 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. + **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__/directory-picker-input.test.tsx b/apps/server/web/src/components/__tests__/directory-picker-input.test.tsx index 9b00e9fe8..3a67dc056 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,42 @@ 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(); + } + }); + + 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 +952,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 +963,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 +998,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 +1029,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 +1040,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 +1057,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 +1069,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 +1090,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 +1111,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 +1145,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__/quick-add.test.tsx b/apps/server/web/src/components/__tests__/quick-add.test.tsx index 218a09f0b..779fe549a 100644 --- a/apps/server/web/src/components/__tests__/quick-add.test.tsx +++ b/apps/server/web/src/components/__tests__/quick-add.test.tsx @@ -30,7 +30,7 @@ function Trigger() { const quickAdd = useQuickAdd(); return (
- From dc6542ab52b463c839003b0e2cf922f4bd2c4236 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Mon, 26 Oct 2026 01:23:36 -0700 Subject: [PATCH 2/3] fix(web): harden quick-add triggers and picker Escape claims openLaunch takes no argument on purpose: handing it to a Button's onClick made the click event the node pick, so the empty-state CTA opened a launch form seeded with a SyntheticEvent. openLaunchOnNode keeps the per-machine entry point, and a required string makes the handler-shaped wiring a compile error. The directory picker's capture-phase Escape claim now covers only events addressed inside the picker, so a popup tabbed to while the panel sat open still gets its dismissal. Pins both regressions. --- apps/server/web/docs/launch-form-picker.md | 4 +- .../__tests__/app-sidebar-account.test.tsx | 1 + .../__tests__/app-sidebar-group.test.tsx | 2 + .../app-sidebar-node-groups.test.tsx | 2 + .../__tests__/directory-picker-input.test.tsx | 40 +++++++++++++++++++ .../__tests__/open-in-browser.test.tsx | 1 + .../src/components/directory-picker-input.tsx | 8 +++- apps/server/web/src/components/quick-add.tsx | 26 ++++++++++-- .../__tests__/all-subshells-section.test.tsx | 1 + .../__tests__/node-launch-menu.test.tsx | 21 +++++++++- .../components/sidebar/node-launch-menu.tsx | 4 +- .../__tests__/home-needs-attention.test.tsx | 1 + 12 files changed, 102 insertions(+), 9 deletions(-) diff --git a/apps/server/web/docs/launch-form-picker.md b/apps/server/web/docs/launch-form-picker.md index f0fc3f88a..6b73e9543 100644 --- a/apps/server/web/docs/launch-form-picker.md +++ b/apps/server/web/docs/launch-form-picker.md @@ -34,7 +34,9 @@ plane never resolves a node path against this host's filesystem. 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. +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 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 3a67dc056..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 @@ -698,6 +698,46 @@ describe("DirectoryPickerInput", () => { } }); + /** + * 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 { 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 bd3ac1887..3ea6310f7 100644 --- a/apps/server/web/src/components/directory-picker-input.tsx +++ b/apps/server/web/src/components/directory-picker-input.tsx @@ -159,7 +159,11 @@ export function DirectoryPickerInput({ } function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { - e.stopPropagation(); + // 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(); } } @@ -615,7 +619,7 @@ function DirRows({ * as a value (null until the listing state has committed it, since the panel * renders this component and the scroller in the same commit). */ scrollerEl: HTMLDivElement | null; - /** Selects and browses into a folder, exactly as a row click does. */ + /** Commits the folder and closes the panel, exactly as a row click does. */ onPick: (path: string) => void; /** Stars or unstars a folder row's path. */ onToggleFavorite: (path: string, on: boolean) => void; diff --git a/apps/server/web/src/components/quick-add.tsx b/apps/server/web/src/components/quick-add.tsx index 9611dc038..18dcbab4e 100644 --- a/apps/server/web/src/components/quick-add.tsx +++ b/apps/server/web/src/components/quick-add.tsx @@ -5,8 +5,23 @@ 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: (nodeId?: string) => void; + /** + * Open the New-subshell launch dialog with its ordinary defaults. + * + * Takes no argument on purpose: these triggers get handed to `onClick` + * and `onAction` props, and React calls those with the click event. A + * node-id parameter here would then SEED the form with a SyntheticEvent + * (the empty-state CTA regression) — an optional parameter still passes + * that assignment check, so the explicit pick lives in its own required- + * argument function, which handler-shaped props cannot accept. + */ + openLaunch: () => void; + /** + * Open the same launch dialog with one machine pre-selected. The pick + * outranks the prior-launch default, `local` included, so it can only be + * called with a real node id. + */ + openLaunchOnNode: (nodeId: string) => void; /** Open the same launch dialog with SSH choices restored. */ openSshLaunch: (initial?: ReturnType) => void; /** Open the New-workspace dialog. */ @@ -45,7 +60,12 @@ export function QuickAddProvider({ children }: { children: ReactNode }): ReactNo // so the empty dependency list is honest. const api = useMemo( () => ({ - openLaunch: (nodeId) => { + openLaunch: () => { + setInitialNodeId(undefined); + setInitialSsh(undefined); + setLaunchOpen(true); + }, + openLaunchOnNode: (nodeId) => { setInitialNodeId(nodeId); setInitialSsh(undefined); setLaunchOpen(true); diff --git a/apps/server/web/src/components/sidebar/__tests__/all-subshells-section.test.tsx b/apps/server/web/src/components/sidebar/__tests__/all-subshells-section.test.tsx index 6e4fc87be..b444aadc9 100644 --- a/apps/server/web/src/components/sidebar/__tests__/all-subshells-section.test.tsx +++ b/apps/server/web/src/components/sidebar/__tests__/all-subshells-section.test.tsx @@ -24,6 +24,7 @@ import { setFetchRouter } from "@/test-setup"; const spy = () => spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openLaunchOnNode: () => {}, openSshLaunch: () => {}, openNewWorkspace: () => {}, }); diff --git a/apps/server/web/src/components/sidebar/__tests__/node-launch-menu.test.tsx b/apps/server/web/src/components/sidebar/__tests__/node-launch-menu.test.tsx index ca719be45..08fb58406 100644 --- a/apps/server/web/src/components/sidebar/__tests__/node-launch-menu.test.tsx +++ b/apps/server/web/src/components/sidebar/__tests__/node-launch-menu.test.tsx @@ -10,7 +10,7 @@ import { } from "@tanstack/react-router"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { StrictMode } from "react"; -import { QuickAddProvider } from "@/components/quick-add"; +import { QuickAddProvider, useQuickAdd } from "@/components/quick-add"; import { NodeLaunchMenu } from "@/components/sidebar/node-launch-menu"; import { setCurrentWorkspace } from "@/lib/current-workspace"; import { setFetchRouter } from "@/test-setup"; @@ -37,14 +37,32 @@ afterEach(() => { setCurrentWorkspace(null); }); +/** + * Mirrors the `/subshells` empty-state CTA wiring: the handler is handed to a + * Button's `onClick` directly (not wrapped), so React calls it with the click + * event. A node-id argument on `openLaunch` would seed the form with that + * SyntheticEvent — a blank machine pick and a `/recent?node=[object Object]` + * request. This is the shape that regression arrives in. + */ +function EmptyStateTrigger() { + const quickAdd = useQuickAdd(); + return ( + + ); +} + 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)[] = []; @@ -84,6 +102,7 @@ async function setup({ const root = createRootRoute({ component: () => ( + {extraTrigger === "empty-state" && } ), diff --git a/apps/server/web/src/components/sidebar/node-launch-menu.tsx b/apps/server/web/src/components/sidebar/node-launch-menu.tsx index 9f8e55536..b9ad483f2 100644 --- a/apps/server/web/src/components/sidebar/node-launch-menu.tsx +++ b/apps/server/web/src/components/sidebar/node-launch-menu.tsx @@ -32,7 +32,7 @@ function ResolvedNodeLaunchMenu({ node, onAction }: { node: Node; onAction?: () const busyRef = useRef(false); const [busy, setBusy] = useState(false); const reason = busy - ? "Wait for the terminal to start." + ? "Wait for the launch to finish." : settings?.lockdown ? "Subshells can't start while lockdown is on." : node.maintenance @@ -90,7 +90,7 @@ function ResolvedNodeLaunchMenu({ node, onAction }: { node: Node; onAction?: () { if (reason) return; - quickAdd.openLaunch(node.id); + quickAdd.openLaunchOnNode(node.id); onAction?.(); }} > diff --git a/apps/server/web/src/routes/__tests__/home-needs-attention.test.tsx b/apps/server/web/src/routes/__tests__/home-needs-attention.test.tsx index a1f959a4b..bbc92f830 100644 --- a/apps/server/web/src/routes/__tests__/home-needs-attention.test.tsx +++ b/apps/server/web/src/routes/__tests__/home-needs-attention.test.tsx @@ -73,6 +73,7 @@ function mount(subshells: SubshellView[], entry = "/subshells"): () => void { }); const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, + openLaunchOnNode: () => {}, openSshLaunch: () => {}, openNewWorkspace: () => {}, }); From 3f47e2f3035fcabb1be2c98e1dc73a2336906601 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Mon, 26 Oct 2026 01:27:00 -0700 Subject: [PATCH 3/3] test(web): exercise the empty-state CTA wiring; tighten quick-add pins The review fix shipped the event-passing trigger in node-launch-menu's setup but no test passed the flag, so the MAJOR's pin never ran. Now it does: the CTA opens the launch dialog on the prior launch's node and directory, and no event ever names a machine to /recent. quick-add's own trigger drops its now-unneeded wrapper for the direct pass that just bit the CTA, and openSshLaunch gains a note that its optional draft is call-by-wrapper only. --- .../src/components/__tests__/quick-add.test.tsx | 2 +- apps/server/web/src/components/quick-add.tsx | 6 +++++- .../sidebar/__tests__/node-launch-menu.test.tsx | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) 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 779fe549a..218a09f0b 100644 --- a/apps/server/web/src/components/__tests__/quick-add.test.tsx +++ b/apps/server/web/src/components/__tests__/quick-add.test.tsx @@ -30,7 +30,7 @@ function Trigger() { const quickAdd = useQuickAdd(); return (
-