From cb07a38a911df5a851716f553fbdb8ee0c65d13a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 19 Oct 2026 11:05:57 -0400 Subject: [PATCH] feat(app): flatten settings with a server switcher Settings is one flat section list with the per-server sections first: Providers, Models, Extensions, Projects, and Worktrees, then the app sections, Servers, and About. With more than one server, per-server pages show a server pill in the title row. It defaults to the server of the tab settings opened from and carries across sections through ?server=. An unreachable server stays selectable and its pages offer Retry and Edit server. The separate per-server settings screen is gone; old server links open the flat pages with that server selected. The Servers page shows one card per server with its connection and terminal shell. --- .../app/e2e/regression/multi-server.spec.ts | 19 +- .../e2e/regression/settings-search.spec.ts | 27 +- .../app/e2e/regression/settings-wsl.spec.ts | 41 ++- packages/app/e2e/regression/settings.spec.ts | 53 ++- packages/app/src/runtime/i18n/en.ts | 8 + packages/app/src/runtime/server/health.ts | 22 +- packages/app/src/runtime/server/runtime.tsx | 9 +- packages/app/src/settings/general/general.tsx | 8 +- packages/app/src/settings/models/models.tsx | 6 +- .../app/src/settings/providers/extensions.tsx | 6 +- .../app/src/settings/providers/providers.tsx | 6 +- packages/app/src/settings/route.test.ts | 93 +++-- packages/app/src/settings/route.ts | 70 ++-- packages/app/src/settings/search-catalog.ts | 7 +- packages/app/src/settings/search-index.ts | 24 +- .../app/src/settings/search-results.test.ts | 76 ++-- packages/app/src/settings/search-results.ts | 12 +- packages/app/src/settings/search.tsx | 5 +- .../app/src/settings/servers/inventory.ts | 6 + packages/app/src/settings/servers/servers.tsx | 212 +++++++---- .../app/src/settings/servers/switcher.tsx | 77 ++++ .../app/src/settings/servers/unavailable.tsx | 86 +++++ packages/app/src/settings/settings.css | 112 ++++++ packages/app/src/settings/shell.tsx | 330 ++++++------------ packages/app/src/settings/surface.tsx | 71 ++-- .../app/src/settings/workspaces/projects.tsx | 18 +- .../src/settings/workspaces/workspaces.tsx | 6 +- packages/app/test-browser/ssh-health.test.ts | 41 ++- 28 files changed, 893 insertions(+), 558 deletions(-) create mode 100644 packages/app/src/settings/servers/switcher.tsx create mode 100644 packages/app/src/settings/servers/unavailable.tsx diff --git a/packages/app/e2e/regression/multi-server.spec.ts b/packages/app/e2e/regression/multi-server.spec.ts index 3fc57b28f42..288cc0b1aa1 100644 --- a/packages/app/e2e/regression/multi-server.spec.ts +++ b/packages/app/e2e/regression/multi-server.spec.ts @@ -120,30 +120,21 @@ test("settings opened from a remote session sweep every server and keep the remo await expect(page).toHaveURL("/settings") await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0) await expect(page.getByRole("dialog")).toHaveCount(0) - await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0) await view.listed(REMOTE_SERVER, directoryB) await expect.poll(() => view.replies).toEqual([reply(sessionA.id, "permission-pending-a")]) - await settings.getByRole("tab", { name: "127.0.0.1:4097", exact: true }).click() - await expect(settings.getByRole("tab")).toHaveText([ - "127.0.0.1:4097", - "Projects", - "Worktrees", - "Providers", - "Models", - "Extensions", - ]) - await settings.getByRole("tab", { name: "Models" }).click() + // Per-server pages start on the server of the tab settings opened from. + await settings.getByRole("tab", { name: "Models", exact: true }).click() + await expect(settings.getByRole("button", { name: "Server 127.0.0.1:4097", exact: true })).toBeVisible() await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled() await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) - await settings.getByRole("button", { name: "Back to settings" }).click() await settings.getByRole("button", { name: "Back to app" }).click() await expect(settings).toBeHidden() await expectPath(page, sessionHref(sessionB.id, REMOTE_SERVER)) await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title) await page.keyboard.press("Control+]") - await expect(page).toHaveURL("/settings") - await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true") + await expect(page).toHaveURL("/settings?tab=models") + await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true") await page.keyboard.press("Escape") await expectPath(page, sessionHref(sessionB.id, REMOTE_SERVER)) await expectSessionTitle(page, sessionB.title) diff --git a/packages/app/e2e/regression/settings-search.spec.ts b/packages/app/e2e/regression/settings-search.spec.ts index 73f76f320ae..7d9bbcf11b5 100644 --- a/packages/app/e2e/regression/settings-search.spec.ts +++ b/packages/app/e2e/regression/settings-search.spec.ts @@ -43,7 +43,8 @@ async function open(page: Page, input: { count?: number; seed?: SeedInput } = {} // Readiness includes the server-backed project inventory, not just the settings shell. if (input.count === 0) return view await view.search.fill("OpenCode") - await expect(view.results.getByRole("option")).toHaveCount(input.count ?? (input.seed?.servers ? 2 : 1)) + // Only the selected server's projects are indexed. + await expect(view.results.getByRole("option")).toHaveCount(input.count ?? 1) await view.search.clear() return view @@ -331,11 +332,11 @@ test("IME confirmation does not activate a search result", async ({ page }) => { await expect(view.settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible() }) -test("multi-server results navigate to the named server and keep the query on return", async ({ page }) => { +test("multi-server results follow the selected server and keep the query on return", async ({ page }) => { await mockOpenCodeServer(page, { server: REMOTE_SERVER, directory: "/remote/opencode", - project: project({ id: "proj_search_remote", directory: "/remote/opencode", name: "OpenCode" }), + project: project({ id: "proj_search_remote", directory: "/remote/opencode", name: "Remote app" }), provider: NO_PROVIDER, sessions: [], pageMessages: () => ({ items: [] }), @@ -347,17 +348,21 @@ test("multi-server results navigate to the named server and keep the query on re }, }) + await view.search.fill("Remote app") + await expect(view.results.getByRole("option")).toHaveCount(0) + await view.search.fill("MCPs") + await expect(view.results.getByRole("option")).toHaveCount(1) + await view.search.clear() + await view.settings.getByRole("tab", { name: "Providers", exact: true }).click() + await view.settings.getByRole("button", { name: "Server 127.0.0.1:4096", exact: true }).click() + await page.getByRole("menuitemradio", { name: "Build server", exact: true }).click() + await view.search.fill("MCPs") - await expect(view.results.getByRole("option")).toHaveCount(2) await view.results.getByRole("option", { name: "MCPs, Build server, Extensions", exact: true }).click() - await expect(view.search).toHaveCount(0) - await expect(view.settings.getByRole("tab", { name: "Build server", exact: true })).toBeVisible() + await expect(view.settings.getByRole("button", { name: "Server Build server", exact: true })).toBeVisible() await expect(view.settings.getByRole("tab", { name: "MCPs", exact: true })).toHaveAttribute("aria-selected", "true") - await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click() - await expect(view.search).toHaveValue("MCPs") - await view.search.fill("Build server OpenCode name") + await view.search.fill("Remote app name") await expect(view.results.getByRole("option")).toHaveCount(1) await view.results.getByRole("option").click() - await expect(view.settings.getByRole("button", { name: "Build server", exact: true })).toBeVisible() - await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("OpenCode") + await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("Remote app") }) diff --git a/packages/app/e2e/regression/settings-wsl.spec.ts b/packages/app/e2e/regression/settings-wsl.spec.ts index a0e8357af97..402f6cacf42 100644 --- a/packages/app/e2e/regression/settings-wsl.spec.ts +++ b/packages/app/e2e/regression/settings-wsl.spec.ts @@ -7,7 +7,7 @@ import { expectSessionTitle } from "../utils/waits" test.use({ viewport: { width: 1280, height: 900 } }) for (const mode of ["failed", "stopped", "ready"] as const) { - test(`manages a ${mode} configured WSL server from nested settings`, async ({ page }) => { + test(`manages a ${mode} configured WSL server from the Servers page`, async ({ page }) => { await mockOpenCodeServer(page, { directory: "/repo", project: project({ id: "proj_wsl_settings", directory: "/repo", name: "WSL project" }), @@ -17,21 +17,27 @@ for (const mode of ["failed", "stopped", "ready"] as const) { }) await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode })}`) const settings = page.getByTestId("settings-screen") - await expect(settings.getByRole("tab", { name: "Local Server", exact: true })).toBeEnabled() - const ubuntu = settings.getByRole("tab", { name: "Ubuntu", exact: true }) - await expect(ubuntu).toHaveCount(1) - await ubuntu.click() - await expect(settings.getByRole("heading", { name: "Ubuntu", exact: true })).toBeVisible() - const connection = settings.locator('[data-component="settings-server-connection"]') + const switcher = settings.getByRole("button", { name: /^Server / }) + await settings.getByRole("tab", { name: "Projects", exact: true }).click() + await switcher.click() + await page.getByRole("menuitemradio", { name: /^Ubuntu/ }).click() + const unavailable = settings.getByRole("status").filter({ hasText: "Can't reach Ubuntu" }) + + if (mode !== "ready") await expect(unavailable).toBeVisible() + + await settings.getByRole("tab", { name: "Servers", exact: true }).click() + const connection = settings.getByRole("region", { name: "Ubuntu", exact: true }) if (mode !== "ready") { - await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeDisabled() await connection.getByRole("button", { name: "More options", exact: true }).click() await page.getByRole("menuitem", { name: "Retry start", exact: true }).click() await expect(page.getByLabel("WSL actions")).toHaveText("start:wsl:Ubuntu") } - await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeEnabled() + await settings.getByRole("tab", { name: "Projects", exact: true }).click() + await expect(switcher).toHaveText("ServerUbuntu") + await expect(unavailable).toHaveCount(0) + await settings.getByRole("tab", { name: "Servers", exact: true }).click() await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click() await expect(page.getByLabel("WSL actions")).toContainText("update:Ubuntu") await expect(connection.getByRole("button", { name: "Update OpenCode", exact: true })).toHaveCount(0) @@ -39,7 +45,7 @@ for (const mode of ["failed", "stopped", "ready"] as const) { await connection.getByRole("button", { name: "More options", exact: true }).click() await page.getByRole("menuitem", { name: "Remove", exact: true }).click() await expect(page.getByLabel("WSL actions")).toContainText("remove:wsl:Ubuntu") - await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(0) + await expect(connection).toHaveCount(0) await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeEnabled() }) } @@ -54,7 +60,8 @@ test("adding a WSL server while the WSL extension is down shows that WSL is unav }) await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready" })}`) const settings = page.getByTestId("settings-screen") - await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(1) + await settings.getByRole("tab", { name: "Servers", exact: true }).click() + await expect(settings.getByRole("region", { name: "Ubuntu", exact: true })).toHaveCount(1) await page.getByRole("checkbox", { name: "WSL extension" }).uncheck() await settings.getByRole("button", { name: "Add server", exact: true }).press("Enter") @@ -75,7 +82,8 @@ test("adding an SSH server while the SSH extension is down fails instead of conn }) await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready" })}`) const settings = page.getByTestId("settings-screen") - await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(1) + await settings.getByRole("tab", { name: "Servers", exact: true }).click() + await expect(settings.getByRole("region", { name: "Ubuntu", exact: true })).toHaveCount(1) await page.getByRole("checkbox", { name: "SSH extension" }).uncheck() await settings.getByRole("button", { name: "Add server", exact: true }).press("Enter") @@ -116,11 +124,10 @@ test("an open session's terminal follows its WSL server to the endpoint it resta await page.keyboard.press("Control+,") const settings = page.getByTestId("settings-screen") - await settings.getByRole("tab", { name: "Ubuntu", exact: true }).click() - const connection = settings.locator('[data-component="settings-server-connection"]') + await settings.getByRole("tab", { name: "Servers", exact: true }).click() + const connection = settings.getByRole("region", { name: "Ubuntu", exact: true }) await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click() await expect(connection.getByRole("button", { name: "Update OpenCode", exact: true })).toHaveCount(0) - await settings.getByRole("button", { name: "Back to settings" }).click() await settings.getByRole("button", { name: "Back to app" }).click() await expectSessionTitle(page, wsl.title) @@ -174,8 +181,8 @@ test("WSL session and draft tabs outlive the extension going away until the serv await page.keyboard.press("Control+,") const settings = page.getByTestId("settings-screen") - await settings.getByRole("tab", { name: "Ubuntu", exact: true }).click() - const connection = settings.locator('[data-component="settings-server-connection"]') + await settings.getByRole("tab", { name: "Servers", exact: true }).click() + const connection = settings.getByRole("region", { name: "Ubuntu", exact: true }) await connection.getByRole("button", { name: "More options", exact: true }).click() await page.getByRole("menuitem", { name: "Remove", exact: true }).click() await expect(page.getByLabel("WSL actions")).toHaveText("remove:wsl:Ubuntu") diff --git a/packages/app/e2e/regression/settings.spec.ts b/packages/app/e2e/regression/settings.spec.ts index 9bfe1ad9dcf..c3c0d76a800 100644 --- a/packages/app/e2e/regression/settings.spec.ts +++ b/packages/app/e2e/regression/settings.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Page } from "@playwright/test" import type { ConfigEntry, OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise" -import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, session } from "../utils/app" +import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, seed, session } from "../utils/app" import { mockMissingLocation, mockOpenCodeServer } from "../utils/mock-server" import { mockRemoteServer, mockWorkspace, openSession, openSettings, type WorkspaceInput } from "../utils/workspace" @@ -134,6 +134,50 @@ test("single-server settings expose scoped pages without a server picker", async expect((await updated).postDataJSON()).toEqual({ shell: "bash" }) }) +test("several servers share one selected server across per-server pages", async ({ page }) => { + const offline = "http://127.0.0.1:4099" + await mockRemoteServer(page, { + name: "Build server", + directory: "/remote/settings-demo", + provider: { + all: [{ id: "openrouter", name: "OpenRouter", models: { kimi: { id: "kimi", name: "Kimi K2.5" } } }], + connected: ["openrouter"], + default: { providerID: "openrouter", modelID: "kimi" }, + }, + integrations: [{ id: "openrouter", name: "OpenRouter", methods: [{ type: "key" }], connections: [] }], + }) + // Nothing answers on the offline server. + await seed(page, { + servers: [ + { url: REMOTE_SERVER, name: "Build server" }, + { url: offline, name: "Offline box" }, + ], + }) + const { settings } = await open(page) + const switcher = settings.locator('[data-action="settings-server-select"]:visible') + const connected = settings.locator('[data-component="connected-providers-section"]') + + await settings.getByRole("tab", { name: "Providers", exact: true }).click() + await expect(switcher).toHaveText("Server127.0.0.1:4096") + await expect(connected.getByText("No connected providers")).toBeVisible() + await switcher.click() + await page.getByRole("menuitemradio", { name: "Build server", exact: true }).click() + await expect(connected.getByText("OpenRouter", { exact: true })).toBeVisible() + await expect(page).toHaveURL( + (url) => url.searchParams.get("server") === REMOTE_SERVER && url.searchParams.get("tab") === "providers", + ) + + await settings.getByRole("tab", { name: "Models", exact: true }).click() + await expect(switcher).toHaveText("ServerBuild server") + await expect(settings.getByRole("switch", { name: "Kimi K2.5", exact: true })).toBeVisible() + + await switcher.click() + await page.getByRole("menuitemradio", { name: /^Offline box/ }).click() + await expect(settings.getByRole("status").filter({ hasText: "Can't reach Offline box" })).toBeVisible() + await expect(settings.getByRole("button", { name: "Retry", exact: true })).toBeVisible() + await expect(settings.getByRole("button", { name: "Edit server", exact: true })).toBeVisible() +}) + test("project list menus rename, edit, add, and stay inside the scrollport", async ({ page }) => { const projects = ["rebase", "dinocms", "opencode", "Playground"].map((name, index) => project({ id: `project-${index}`, directory: `/projects/${name}`, name }), @@ -1280,7 +1324,7 @@ test("the add server dialog keeps focus above fullscreen settings", async ({ pag await mockRemoteServer(page, { directory: "/remote/settings-demo" }) const { settings } = await open(page) await expect(page.getByRole("dialog")).toHaveCount(0) - await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover() + await settings.getByRole("tab", { name: "Servers", exact: true }).click() await settings.getByRole("button", { name: "Add server" }).click() const editor = page.getByRole("dialog", { name: "Add server" }) @@ -1315,7 +1359,7 @@ test("the add server dialog pairs from a one-time link and explains a spent one" const link = `${paired}/auth/connect/one-time-code` const addServer = async () => { - await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover() + await settings.getByRole("tab", { name: "Servers", exact: true }).click() await settings.getByRole("button", { name: "Add server" }).click() const editor = page.getByRole("dialog", { name: "Add server" }) await editor.getByLabel("Pairing link", { exact: true }).fill(link) @@ -1327,7 +1371,6 @@ test("the add server dialog pairs from a one-time link and explains a spent one" const added = await addServer() await expect(added).toBeHidden() await expect(settings.getByText(paired, { exact: true })).toBeVisible() - await settings.getByRole("button", { name: "Back to settings" }).click() const spent = await addServer() await expect(spent.getByRole("alert")).toHaveText( @@ -1359,7 +1402,7 @@ test("a pairing code with several addresses keeps the first one that works with } const { settings } = await open(page) - await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover() + await settings.getByRole("tab", { name: "Servers", exact: true }).click() await settings.getByRole("button", { name: "Add server" }).click() const editor = page.getByRole("dialog", { name: "Add server" }) await editor diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 41c2b71b857..5de8b1e56b5 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -769,6 +769,14 @@ export const dict = { "settings.servers.description": "Manage server connections", "settings.server.description": "Manage this server’s connection and preferences", "settings.server.section.connection": "Connection", + "settings.server.switcher.label": "Server", + "settings.server.switcher.unreachable": "Unreachable", + "settings.server.switcher.manage": "Manage servers…", + "settings.server.unreachable.title": "Can't reach {{server}}", + "settings.server.unreachable.description": + "{{server}} isn't responding. Its settings will appear here once it reconnects.", + "settings.server.unreachable.retry": "Retry", + "settings.server.unreachable.edit": "Edit server", "settings.server.preferences.websearch.title": "Third-party search", "settings.server.preferences.websearch.description": "Select the search provider agents use to search the web", "settings.projects.title": "Projects", diff --git a/packages/app/src/runtime/server/health.ts b/packages/app/src/runtime/server/health.ts index 580b3068456..1a8f619d041 100644 --- a/packages/app/src/runtime/server/health.ts +++ b/packages/app/src/runtime/server/health.ts @@ -2,7 +2,7 @@ import { usePlatform } from "@/runtime/platform/platform" import { ServerConnection } from "@/runtime/server/registry" import { authTokenFromCredentials } from "./api" import { ClientError, isUnauthorizedError, OpenCode } from "@opencode/client" -import { Accessor, createEffect, onCleanup } from "solid-js" +import { Accessor, createEffect, createSignal, onCleanup } from "solid-js" import { createStore, reconcile } from "solid-js/store" export type ServerHealth = { @@ -174,12 +174,18 @@ export function createServerHealth( check: (http: ServerConnection.HttpBase) => Promise, ) { const [status, setStatus] = createStore({} as Record) + const [attempt, setAttempt] = createSignal(0) const endpoints = new Map() + // Callers of `retry` wait for the probe round it starts. + const waiting: (() => void)[] = [] createEffect(() => { + attempt() + if (!enabled()) { endpoints.clear() setStatus(reconcile({})) + waiting.splice(0).forEach((resolve) => resolve()) return } @@ -250,7 +256,9 @@ export function createServerHealth( setStatus(reconcile(results)) } - void refresh() + void refresh().finally(() => { + if (!dead) waiting.splice(0).forEach((resolve) => resolve()) + }) const id = setInterval(() => void refresh(), pollMs) onCleanup(() => { dead = true @@ -258,5 +266,13 @@ export function createServerHealth( }) }) - return status + return { + status, + /** Probes every server now instead of at the next poll; resolves when that round finishes. */ + retry: () => + new Promise((resolve) => { + waiting.push(resolve) + setAttempt((value) => value + 1) + }), + } } diff --git a/packages/app/src/runtime/server/runtime.tsx b/packages/app/src/runtime/server/runtime.tsx index 6cbdf74ec97..69c148ac125 100644 --- a/packages/app/src/runtime/server/runtime.tsx +++ b/packages/app/src/runtime/server/runtime.tsx @@ -81,14 +81,14 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( // credentials, so its controller waits until health recovers and then starts with the current ones. createMemo(() => { for (const conn of server.list) { - if (serverHealth[ServerConnection.key(conn)]?.unauthorized) continue + if (serverHealth.status[ServerConnection.key(conn)]?.unauthorized) continue ensureServerCtx(conn) } }) createEffect(() => { for (const [key] of serverCtxs) { - if (serverHealth[key]?.unauthorized || !server.list.find((conn) => ServerConnection.key(conn) === key)) + if (serverHealth.status[key]?.unauthorized || !server.list.find((conn) => ServerConnection.key(conn) === key)) disposeServerCtx(key) } }) @@ -96,7 +96,8 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( return { servers: { list: () => server.list, - health: serverHealth, + health: serverHealth.status, + retry: serverHealth.retry, }, models, ensureServerCtx(conn: ServerConnection.Any) { @@ -106,7 +107,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( serverCtx(key: ServerConnection.Key) { const conn = server.list.find((item) => ServerConnection.key(item) === key) - if (!conn || serverHealth[key]?.unauthorized) return + if (!conn || serverHealth.status[key]?.unauthorized) return return ensureServerCtx(conn) }, diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index 910492c8409..76e14348dc8 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -62,7 +62,11 @@ const WorkspaceDestinationSetting: Component = () => { ) } -export const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { +export const ShellSetting: Component<{ + controller: ShellSettingsController + /** Search reveals the row with the default action; the Servers page names the other servers' rows apart. */ + action?: string +}> = (props) => { const language = useLanguage() const options = createMemo(() => @@ -78,7 +82,7 @@ export const ShellSetting: Component<{ controller: ShellSettingsController }> = description={language.t("settings.general.row.shell.description")} >