From e5bd37744f8415a2e3ecbe8d22d8ffd3b485a9b3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 21 Oct 2026 20:58:30 -0700 Subject: [PATCH] feat(app): open Extensions config and skills in a side tab Configuration file opens the file that lists a service's entries in a side tab, instead of revealing the last file that mentions the service. An empty project "mcp": {} no longer hides the global file's servers. Skills open their SKILL.md the same way. Empty lists, and the skills list, offer to start a setup session: a draft in the global configuration's directory with its prompt filled in, through a new Layout.draft SDK method. --- .../extension-host.fixture.tsx | 2 + .../e2e/regression/session-summary.spec.ts | 51 ++++ .../app/src/runtime/extension/host-apis.tsx | 12 +- .../src/browser/panel.fixture.tsx | 1 + .../src/details/config-file.test.ts | 64 +++++ .../gui-extensions/src/details/config-file.ts | 47 ++++ .../gui-extensions/src/details/details.css | 15 +- .../gui-extensions/src/details/i18n/en.ts | 12 + packages/gui-extensions/src/details/panel.tsx | 3 + .../gui-extensions/src/details/popover.tsx | 2 +- .../src/details/server-panel.tsx | 222 ++++++++++-------- packages/gui-extensions/src/sdk/host-apis.ts | 19 ++ 12 files changed, 343 insertions(+), 107 deletions(-) create mode 100644 packages/gui-extensions/src/details/config-file.test.ts create mode 100644 packages/gui-extensions/src/details/config-file.ts diff --git a/packages/app/component-tests/extension-host.fixture.tsx b/packages/app/component-tests/extension-host.fixture.tsx index 7e795a9834ce..7bffd9ea6e09 100644 --- a/packages/app/component-tests/extension-host.fixture.tsx +++ b/packages/app/component-tests/extension-host.fixture.tsx @@ -74,6 +74,7 @@ const layout: Layout = { scroll: { get: () => undefined, set() {} }, settings() {}, project() {}, + draft() {}, command() {}, } @@ -349,6 +350,7 @@ function standIn(overrides: Partial): Interface { }, settings() {}, project() {}, + draft() {}, command() {}, font: () => "fixture mono", routing: () => false, diff --git a/packages/app/e2e/regression/session-summary.spec.ts b/packages/app/e2e/regression/session-summary.spec.ts index a88802717e17..badb004d1739 100644 --- a/packages/app/e2e/regression/session-summary.spec.ts +++ b/packages/app/e2e/regression/session-summary.spec.ts @@ -670,6 +670,57 @@ test("Extensions lists open on hover, hold course into the open list, and follow await expect(summary).toBeHidden() }) +test("Extensions lists open config files and skills in a side tab and start setup sessions", async ({ page }) => { + const global = "C:/Users/kit/.config/opencode/opencode.jsonc" + const skill = `${fixture.directory}/.opencode/skills/release-notes/SKILL.md` + + await mockStressTimeline(page, { + mcp: [{ name: "atlas-search", status: { status: "connected" } }], + plugins: [], + skills: [{ id: "release-notes", name: "release-notes", path: skill, content: "Release notes" }], + configEntries: [ + { + type: "document", + path: global, + info: { mcp: { servers: { "atlas-search": { type: "remote", url: "https://atlas.example" } } } }, + }, + // An empty project override used to win, so the servers' own file never opened. + { type: "document", path: `${fixture.directory}/.opencode/opencode.jsonc`, info: { mcp: {} } }, + ], + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + }) + await page.goto(sessionHref(fixture.targetID)) + const trigger = page.getByRole("button", { name: "Session details", exact: true }) + const summary = page.getByRole("dialog", { name: "Session details", exact: true }) + const row = (name: string) => summary.getByRole("button", { name, exact: true }) + const list = (name: string) => page.getByRole("group", { name, exact: true }) + + await trigger.click() + await row("MCP").hover() + // A file outside the workspace is read by name from its own directory. + const read = page.waitForRequest((request) => new URL(request.url()).pathname.endsWith("/api/fs/read/opencode.jsonc")) + await list("MCP").getByRole("button", { name: "Configuration file", exact: true }).click() + expect(new URL((await read).url()).searchParams.get("location[directory]")).toBe("C:/Users/kit/.config/opencode/") + await expect(summary).toBeHidden() + await expect(page.getByRole("tab", { name: "opencode.jsonc", exact: true })).toHaveAttribute("aria-selected", "true") + await expect(page.getByText("contents:opencode.jsonc", { exact: true })).toBeVisible() + + await trigger.click() + await row("Skills").hover() + await list("Skills").getByRole("button", { name: "release-notes", exact: true }).click() + await expect(summary).toBeHidden() + await expect(page.getByRole("tab", { name: "SKILL.md", exact: true })).toHaveAttribute("aria-selected", "true") + + // An empty list offers a draft session in the global configuration's directory, its prompt filled in. + await trigger.click() + await row("Plugins").hover() + await list("Plugins").getByRole("button", { name: "Add a plugin with OpenCode", exact: true }).click() + await expect(page).toHaveURL(/\/new-session\?/) + await expect(page.locator('[data-component="composer-editor"]')).toContainText( + "Help me add a plugin to my global OpenCode config.", + ) +}) + test("multiple desktop connections show the session's server name", async ({ page }) => { await mockStressTimeline(page) await page.route("http://secondary.test/**", (route) => diff --git a/packages/app/src/runtime/extension/host-apis.tsx b/packages/app/src/runtime/extension/host-apis.tsx index bc3fcefe6caa..deac6e88f60d 100644 --- a/packages/app/src/runtime/extension/host-apis.tsx +++ b/packages/app/src/runtime/extension/host-apis.tsx @@ -62,11 +62,12 @@ type Attached = { scope: (server: string) => ServerScope /** Records a session-scoped store so layout pruning drops it with the session. */ scoped: (name: string) => void - layout: Omit & { + layout: Omit & { stored(extension: string, session: SessionRef): readonly string[] } settings: (page?: string) => void project: (server: string, title: string) => void + draft: Layout["draft"] command: (id: string) => void font: Accessor routing: Accessor @@ -322,6 +323,7 @@ export function createHostApis() { }, settings: (page) => write((value) => value.settings(page)), project: (server, title) => write((value) => value.project(server, title)), + draft: (input) => write((value) => value.draft(input)), command: (id) => write((value) => value.command(id)), }), embeds: () => embeds, @@ -651,6 +653,7 @@ export function createExtensionAttachment(apis: HostApis) { // Writes made before a session's location is known wait for it rather than being dropped. const located = createLocatedWrites() + // Kept per session beside its strip, so the new tab view still lists them after a reload. const closed = createClosedTabs({ list(session) { @@ -711,10 +714,12 @@ export function createExtensionAttachment(apis: HostApis) { const value = stateKey(session) if (!value) return located.hold(session, () => close(key, session)) + const tab = listed(session, value).find((entry) => entry.key === key)?.tab const all = layout.panel.state(value).all const view = mountedSession(session) const currentScreen = screen() + const entry = view && tab && @@ -840,6 +845,11 @@ export function createExtensionAttachment(apis: HostApis) { scope, scoped: layout.sessionState.track, project: (server, title) => setProjects((pending) => [...pending, { server, title }]), + draft: (input) => { + const server = servers.list.find((conn) => ServerConnection.key(conn) === input.server) + + if (server) void tabs.newDraft({ server: ServerConnection.key(server), directory: input.directory }, input.prompt) + }, command: (id) => command.trigger(id), font: () => terminalFontFamily(settings.appearance.terminalFont()), routing: useIsRouting(), diff --git a/packages/gui-extensions/src/browser/panel.fixture.tsx b/packages/gui-extensions/src/browser/panel.fixture.tsx index 6cf2f72ea458..93b55764a940 100644 --- a/packages/gui-extensions/src/browser/panel.fixture.tsx +++ b/packages/gui-extensions/src/browser/panel.fixture.tsx @@ -824,6 +824,7 @@ export function mountBrowserRegion(input: RegionHost) { scroll: { get: () => undefined, set() {} }, settings() {}, project() {}, + draft() {}, command() {}, }) diff --git a/packages/gui-extensions/src/details/config-file.test.ts b/packages/gui-extensions/src/details/config-file.test.ts new file mode 100644 index 000000000000..5c174c091a8d --- /dev/null +++ b/packages/gui-extensions/src/details/config-file.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test" +import { globalConfigDirectory, serviceConfigFile } from "./config-file" + +const global = "/home/kit/.config/opencode/opencode.jsonc" + +const project = "/work/acme/.opencode/opencode.jsonc" + +test("a project file that sets a service without entries does not hide the global file's entries", () => { + const entries = [ + { + type: "document" as const, + path: global, + info: { + mcp: { servers: { atlas: { type: "remote" as const, url: "https://atlas.example" } } }, + plugins: ["opencode-tidewater"], + }, + }, + { + type: "document" as const, + path: project, + info: { mcp: {}, plugins: [], lsp: { zephyr: { command: ["zephyr"] } } }, + }, + { type: "document" as const, path: "/work/acme/.opencode/agents/review.md", info: {} }, + ] + + expect(serviceConfigFile(entries, "mcp")).toBe(global) + expect(serviceConfigFile(entries, "plugins")).toBe(global) + expect(serviceConfigFile(entries, "lsp")).toBe(project) + // Nothing lists skills, so the first file, the global one, is where they would go. + expect(serviceConfigFile(entries, "skills")).toBe(global) +}) + +test("a file that only sets a service still wins over one that never mentions it", () => { + const entries = [ + { type: "document" as const, path: global, info: {} }, + { type: "document" as const, path: project, info: { lsp: false } }, + ] + + expect(serviceConfigFile(entries, "lsp")).toBe(project) + expect(serviceConfigFile([], "lsp")).toBeUndefined() +}) + +test("setup sessions run in the global configuration's directory, never an ancestor project's", () => { + // In the server's order: global files and root, an ancestor's config outside the worktree, then the project's. + const ancestor = "/work/opencode.json" + const projectRoot = { type: "directory" as const, path: "/work/acme/.opencode" } + + const entries = [ + { type: "document" as const, path: global, info: {} }, + { type: "directory" as const, path: "/home/kit/.config/opencode" }, + { type: "document" as const, path: ancestor, info: {} }, + { type: "document" as const, path: project, info: {} }, + projectRoot, + ] + + expect(globalConfigDirectory(entries, "/work/acme")).toBe("/home/kit/.config/opencode") + // With global configuration off, the ancestor's directory is still not taken for the global one. + expect( + globalConfigDirectory( + [{ type: "document", path: ancestor, info: {} }, { type: "document", path: project, info: {} }, projectRoot], + "/work/acme", + ), + ).toBe("/work/acme") +}) diff --git a/packages/gui-extensions/src/details/config-file.ts b/packages/gui-extensions/src/details/config-file.ts new file mode 100644 index 000000000000..71df205d836c --- /dev/null +++ b/packages/gui-extensions/src/details/config-file.ts @@ -0,0 +1,47 @@ +import type { ConfigEntry } from "@opencode/client" +import { Predicate } from "effect" + +type ConfigInfo = Extract["info"] + +/** + * The configuration file that holds a service's entries: the last one that lists any, else the last one that sets the + * service, else the first. An empty `"mcp": {}` in a project file does not hide the servers the global file lists. + */ +export function serviceConfigFile(entries: readonly ConfigEntry[], service: "mcp" | "plugins" | "skills" | "lsp") { + const documents = configDocuments(entries) + + return ( + documents.findLast((document) => lists(document.info, service)) ?? + documents.findLast((document) => document.info[service] !== undefined) ?? + documents.at(0) + )?.path +} + +/** + * The global configuration directory, else the location itself. The server lists a `directory` entry for each config + * root, the global one before any project's, and a project root is always a `.opencode` folder. So the first root is + * the global one unless it is a project's, which means global configuration is off. A project config in an ancestor of + * the worktree is not mistaken for it. + */ +export function globalConfigDirectory(entries: readonly ConfigEntry[], directory: string) { + const root = entries.find((entry) => entry.type === "directory") + + return root && !/[\\/]\.opencode[\\/]?$/.test(root.path) ? root.path.replace(/[\\/]+$/, "") : directory +} + +function configDocuments(entries: readonly ConfigEntry[]) { + return entries.flatMap((entry) => + entry.type === "document" && entry.path !== undefined && /\.jsonc?$/.test(entry.path) + ? [{ path: entry.path, info: entry.info }] + : [], + ) +} + +function lists(info: ConfigInfo, service: "mcp" | "plugins" | "skills" | "lsp") { + if (service === "mcp") return Object.keys(info.mcp?.servers ?? {}).length > 0 + + if (service === "lsp") + return info.lsp !== undefined && !Predicate.isBoolean(info.lsp) && Object.keys(info.lsp).length > 0 + + return (info[service]?.length ?? 0) > 0 +} diff --git a/packages/gui-extensions/src/details/details.css b/packages/gui-extensions/src/details/details.css index 4658b53ba8d2..72d36a2d3131 100644 --- a/packages/gui-extensions/src/details/details.css +++ b/packages/gui-extensions/src/details/details.css @@ -70,6 +70,7 @@ .session-summary-row:focus-visible, .session-service-config:focus-visible, +button.session-service-row:focus-visible, .session-mcp-row:focus-within { outline: none; background: var(--v2-overlay-simple-overlay-hover); @@ -78,6 +79,7 @@ @media (hover: hover) { button.session-summary-row:hover, .session-service-config:not(:disabled):hover, + button.session-service-row:hover, .session-summary-move:has(> .session-summary-dismiss:hover) > .session-summary-row, .session-mcp-row:not([data-disabled]):hover { background: var(--v2-overlay-simple-overlay-hover); @@ -315,15 +317,6 @@ text-decoration: none; cursor: pointer; - .session-service-config-arrow { - opacity: 0; - transition: opacity 150ms ease-out; - } - - &:is(:hover, :focus-visible) .session-service-config-arrow { - opacity: 1; - } - &:focus-visible { outline: 2px solid var(--v2-border-border-focus); outline-offset: 2px; @@ -500,8 +493,4 @@ div.session-mcp-row { translate: none; transition: opacity 0.15s ease-out; } - - .session-service-config .session-service-config-arrow { - transition: none; - } } diff --git a/packages/gui-extensions/src/details/i18n/en.ts b/packages/gui-extensions/src/details/i18n/en.ts index 286b0a85a601..418c4a60b86a 100644 --- a/packages/gui-extensions/src/details/i18n/en.ts +++ b/packages/gui-extensions/src/details/i18n/en.ts @@ -23,6 +23,18 @@ export default { configure: "Configuration file", copyConfigPath: "Copy configuration file path", configFileMissing: "No configuration file found", + "setup.mcp": "Add a server with OpenCode", + "setup.mcp.prompt": + "Help me add an MCP server to my global OpenCode config. Ask me which service I want to connect, then set it up and check that it connects.", + "setup.plugins": "Add a plugin with OpenCode", + "setup.plugins.prompt": + "Help me add a plugin to my global OpenCode config. Ask me what I want it to do, suggest a plugin, then add it.", + "setup.skills": "New skill with OpenCode", + "setup.skills.prompt": + "Help me write a new OpenCode skill in my global config. Ask me what it should teach you, then write its SKILL.md.", + "setup.lsp": "Set up with OpenCode", + "setup.lsp.prompt": + "Help me configure a language server in my global OpenCode config. Ask me which language I work in, then set it up.", loadingChanges: "Loading changes…", noChanges: "No changes", basedOn: "Based on {{branch}}", diff --git a/packages/gui-extensions/src/details/panel.tsx b/packages/gui-extensions/src/details/panel.tsx index 03b40c5db389..b15a9a2183ec 100644 --- a/packages/gui-extensions/src/details/panel.tsx +++ b/packages/gui-extensions/src/details/panel.tsx @@ -5,6 +5,8 @@ export type DetailsPanelProps = { session: MountedSession shown?: boolean mobile?: boolean + /** Closes the popover that shows the panel, once an action opened something beside it. */ + onClose?: () => void } export default function SessionDetailsPanel(props: DetailsPanelProps) { @@ -15,6 +17,7 @@ export default function SessionDetailsPanel(props: DetailsPanelProps) { directory={props.session.directory} shown={props.shown !== false} mobile={props.mobile} + onClose={props.onClose} /> ) diff --git a/packages/gui-extensions/src/details/popover.tsx b/packages/gui-extensions/src/details/popover.tsx index 6e8ac69d92ff..4c39b9837f6c 100644 --- a/packages/gui-extensions/src/details/popover.tsx +++ b/packages/gui-extensions/src/details/popover.tsx @@ -25,7 +25,7 @@ export function DetailsHeader(props: { - + setOpen(false)} /> diff --git a/packages/gui-extensions/src/details/server-panel.tsx b/packages/gui-extensions/src/details/server-panel.tsx index 2881cd0d7bd9..8c7186092261 100644 --- a/packages/gui-extensions/src/details/server-panel.tsx +++ b/packages/gui-extensions/src/details/server-panel.tsx @@ -7,7 +7,6 @@ import { Switch } from "@opencode/ui/switch" import { showToast } from "@opencode/ui/toast" import { Tooltip } from "@opencode/ui/tooltip" import { TruncatedText } from "@opencode/ui/truncated-text" -import { getDirectory } from "@opencode/util/path" import { useMutation } from "@tanstack/solid-query" import { children, @@ -25,6 +24,7 @@ import { } from "solid-js" import { createStore } from "solid-js/store" import { createKeyed, useDrawer, useExtension, type MountedSession } from "../sdk" +import { globalConfigDirectory, serviceConfigFile } from "./config-file" import { configuredLsps } from "./configured-lsp" const services = [ @@ -40,6 +40,8 @@ type ServiceProps = { session: MountedSession directory: string shown: boolean + /** Closes the details popover, once an action opened something beside it. */ + onClose?: () => void } /** One service's list: its load state, what its row summarises, and the list itself. */ @@ -77,6 +79,7 @@ export function SessionServerPanel(props: ServiceProps & { mobile?: boolean }) { shown={props.shown} mobile={props.mobile} name={name()} + onClose={props.onClose} /> )} @@ -517,12 +520,7 @@ function useLsp(props: ServiceProps): ServiceSource { failed: false, retry: () => void refetch(), Body: () => ( - - } - > + }> {(name) => (
@@ -531,7 +529,8 @@ function useLsp(props: ServiceProps): ServiceSource { )} ), @@ -583,12 +582,7 @@ function useMcp(props: ServiceProps, close: () => void): ServiceSource { }, retry: () => void refetch(), Body: () => ( - - } - > + }> {(server) => { const enabled = () => server().status.status !== "disabled" @@ -676,7 +670,8 @@ function useMcp(props: ServiceProps, close: () => void): ServiceSource { }} + } + > + {(path) => ( + // A skill opens its SKILL.md in a side tab. + + )} + )} ), @@ -866,11 +885,10 @@ function ServiceBody(props: { source: ServiceSource; children: JSX.Element }) { ) } -function ServiceConfigLink(props: { session: MountedSession; directory: string; service: Service }) { +function ServiceConfigLink(props: { owner: ServiceProps; service: Service }) { const ctx = useExtension() - const desktop = ctx.desktop const system = ctx.system - const local = () => props.session.server.local + const local = () => props.owner.session.server.local const [store, setStore] = createStore({ opening: false, copied: false }) const label = () => ctx.t(local() ? "configure" : "copyConfigPath") // Clears the copied mark two seconds after the latest copy. @@ -879,31 +897,28 @@ function ServiceConfigLink(props: { session: MountedSession; directory: string; onCleanup(() => clearTimeout(reset)) const activate = async () => { - if (store.opening || (local() && !desktop)) return + if (store.opening) return setStore({ opening: true, copied: false }) - const directory = props.directory - await props.session.server.client.config - .get({ location: { directory } }) + const session = props.owner.session + await session.server.client.config + .get({ location: { directory: props.owner.directory } }) .then(async (entries) => { - const documents = entries - .filter((entry) => entry.type === "document") - .filter((entry) => entry.path !== undefined && /\.jsonc?$/.test(entry.path)) + const path = serviceConfigFile(entries, props.service) - const path = - documents.findLast((entry) => entry.info[props.service] !== undefined)?.path ?? documents.at(-1)?.path + if (!path) throw new Error(ctx.t("configFileMissing")) - if (!local()) { - if (!path) throw new Error(ctx.t("configFileMissing")) - await system.copy(path) - setStore("copied", true) - clearTimeout(reset) - reset = setTimeout(() => setStore("copied", false), 2000) + // A local file opens in a side tab beside the session, inside the workspace or not. + if (local()) { + if (ctx.links.open({ href: path, session })) return props.owner.onClose?.() + await ctx.desktop?.reveal(path) return } - if (path && (await desktop?.reveal(path))) return - await desktop?.launch(path ? getDirectory(path) : directory) + await system.copy(path) + setStore("copied", true) + clearTimeout(reset) + reset = setTimeout(() => setStore("copied", false), 2000) }) .catch((error) => showToast({ @@ -916,55 +931,78 @@ function ServiceConfigLink(props: { session: MountedSession; directory: string; } return ( - <> - - - - {label()} - - } + anchor?.querySelector("svg")?.getBoundingClientRect()} + forceOpen={store.copied ? true : undefined} + class="w-full" + > + - - - + + {label()} + + + ) +} + +// Starts a draft session in the global configuration's directory that asks OpenCode to set the service up. +function ServiceSetup(props: { owner: ServiceProps; service: Service }) { + const ctx = useExtension() + const [store, setStore] = createStore({ opening: false }) + + const start = async () => { + if (store.opening) return + setStore("opening", true) + const session = props.owner.session + await session.server.client.config + .get({ location: { directory: props.owner.directory } }) + .then((entries) => { + ctx.layout.draft({ + server: session.server.id, + directory: globalConfigDirectory(entries, props.owner.directory), + prompt: ctx.t(`setup.${props.service}.prompt`), + }) + props.owner.onClose?.() + }) + .catch((error) => + showToast({ + variant: "error", + title: ctx.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + ) + .finally(() => setStore("opening", false)) + } + + return ( + ) } -function ServiceEmpty(props: { session: MountedSession; title: string; directory: string; service: Service }) { +function ServiceEmpty(props: { owner: ServiceProps; title: string; service: Service }) { return (
{props.title}
) diff --git a/packages/gui-extensions/src/sdk/host-apis.ts b/packages/gui-extensions/src/sdk/host-apis.ts index 7610e3d0eb40..ae353a2ec529 100644 --- a/packages/gui-extensions/src/sdk/host-apis.ts +++ b/packages/gui-extensions/src/sdk/host-apis.ts @@ -792,6 +792,25 @@ export interface Layout { * @param title - The picker's title. */ project(server: string, title: string): void + /** + * Opens a new draft session in a directory on a server with its prompt filled in, for the user to review and send. + * The draft opens in its own tab, as a new session from Home does. + * + * @param input - Where the draft runs and what it asks. + * + * @example + * ```ts + * ctx.layout.draft({ server: session.server.id, directory, prompt: ctx.t("setup.prompt") }) + * ``` + */ + draft(input: { + /** The server's `ServerRef.id`. */ + readonly server: string + /** The directory the new session runs in. */ + readonly directory: string + /** The prompt the draft starts with. */ + readonly prompt: string + }): void /** * Runs an app command as the command palette does, e.g. `mcp.toggle` (the `/mcp` dialog) or `session.export`. * Does nothing when the command is unavailable or disabled.