Skip to content

web ui: composer agent and model-variant selectors announce a static label instead of the selected value #57464

Open

Description

@pranam-s

Description

The composer "Choose agent" and "Choose model variant" buttons expose a static accessible name, so a screen reader never hears which value is selected.

NVDA on Windows announces:

Choose agent          menu button collapsed subMenu
Choose model variant  menu button collapsed subMenu

The model button next to them announces its value:

MiniMax M3.1 Flash Free   menu button collapsed subMenu

The value is rendered visibly inside the button (packages/app/src/composer/editor/editor.tsx:870), but the trigger also sets aria-label="Choose agent" / aria-label="Choose model variant" (editor.tsx:902 and editor.tsx:929). aria-label takes precedence over the element's text content when the accessible name is computed, so the value is dropped.

That also fails WCAG 2.2 SC 2.5.3 Label in Name, which requires the accessible name to contain the visible text.

The model button works because it sets no aria-label at all. Its name comes from the text content (packages/app/src/composer/composer.tsx:82).

Steps to reproduce

  1. Open the web UI with a screen reader (NVDA on Windows, Chrome or Edge).
  2. Focus the "Choose agent" button in the composer.
  3. Listen to the accessible name.
  4. Repeat for "Choose model variant".

Scope

This issue covers the two composer triggers. The provider account menu (packages/app/src/settings/providers/providers.tsx:328) and the home project dropdown (packages/app/src/home/projects/view.tsx:100) hide their value the same way, but each needs its own copy, so they are tracked separately.

Plugins

None

OpenCode version

2.1.1

Screenshot

N/A

Operating System

Windows 11

Terminal

Chrome / Edge (web UI)

Activity

  1. opencode-agent commented on Oct 20, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I can confirm this on 2.1.1, and it still happens on the latest dev (772e2ae).

    Steps I used:

    1. Ran opencode serve (2.1.1) in a project. I configured a dummy provider whose model has variants, so the variant selector appears.
    2. Opened the web UI in Chromium, turned on Settings › General › "Show agent", and opened a new session.
    3. Read the buttons' accessible names from Chrome's accessibility tree.

    Result:

    Button Accessible name Visible text
    Agent Choose agent Build
    Model Mock Model Mock Model
    Variant Choose model variant Default

    The agent and variant triggers in packages/app/src/composer/editor/editor.tsx still set aria-label={props.title}, so the selected value isn't part of the name. I only checked the provider-accounts and Projects buttons you mentioned in the code, where they have the same pattern; I didn't test them in the browser.

    The script below reproduces it. It prints a table of each popup button's accessible name next to its visible text.

    Reproduction script (a11y.mjs)
    // Reproduces #57464: composer agent / model-variant triggers expose a static accessible
    // name instead of the selected value shown in the button.
    // Setup: a git project at /tmp/opencode/proj with this opencode.json (a dummy provider whose model has
    // variants, so the variant selector appears; no request is ever sent):
    //   { "providers": { "mock": { "name": "Mock", "env": ["MOCK_API_KEY"], "package": "@ai-sdk/openai-compatible",
    //       "settings": { "baseURL": "http://127.0.0.1:9/v1" },
    //       "models": { "mock-model": { "name": "Mock Model", "variants": [{ "id": "low" }, { "id": "high" }] } } } },
    //     "model": "mock/mock-model" }
    // Needs: npm i playwright && npx playwright install --with-deps chromium
    // Usage: MOCK_API_KEY=x opencode serve --hostname 127.0.0.1 --port 4096 (from the project), then
    //   OC_URL=http://127.0.0.1:4096 OC_PASSWORD=<server password> node a11y.mjs
    import { chromium } from "playwright"
    
    const url = process.env.OC_URL ?? "http://127.0.0.1:4096"
    const password = process.env.OC_PASSWORD
    const project = process.env.OC_PROJECT ?? "/tmp/opencode/proj"
    
    // Create a session in the project so it shows up on the home screen.
    await fetch(`${url}/api/session?directory=${encodeURIComponent(project)}`, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: "Basic " + Buffer.from(`opencode:${password}`).toString("base64"),
      },
      body: JSON.stringify({ directory: project }),
    })
    
    const browser = await chromium.launch()
    const context = await browser.newContext({
      viewport: { width: 1400, height: 900 },
      ...(password ? { httpCredentials: { username: "opencode", password } } : {}),
    })
    const page = await context.newPage()
    await page.goto(`${url}/?auth_token=${Buffer.from(`opencode:${password}`).toString("base64")}`)
    await page.waitForTimeout(4000)
    await page.screenshot({ path: "/workspace/media/home.png" })
    
    // Enable Settings > General > "Show agent" so the composer shows the agent selector.
    await page.getByText("Settings", { exact: true }).first().click()
    await page.waitForTimeout(1500)
    const sw = page.locator('[data-action="settings-show-custom-agents"] input, [data-action="settings-show-custom-agents"] [role=switch]').first()
    if (!(await sw.isChecked().catch(() => false))) await page.locator('[data-action="settings-show-custom-agents"]').click()
    await page.waitForTimeout(500)
    await page.keyboard.press("Escape")
    await page.waitForTimeout(1000)
    await page.screenshot({ path: "/workspace/media/settings.png" })
    
    // Try to reach a composer: open the project from home if needed.
    if ((await page.locator('[aria-label="Choose agent"]').count()) === 0) {
      // Open the project from the home screen, then start a new session in it (Ctrl+T).
      await page.getByText(project.split("/").pop(), { exact: true }).first().click().catch(() => {})
      await page.waitForTimeout(1500)
      await page.keyboard.press("Control+t")
      await page.waitForTimeout(3000)
    }
    
    const cdp = await context.newCDPSession(page)
    await cdp.send("Accessibility.enable")
    const { nodes } = await cdp.send("Accessibility.getFullAXTree")
    const buttons = []
    for (const n of nodes) {
      const role = n.role?.value
      if (role !== "button" && role !== "combobox") continue
      if (!n.properties?.some((p) => p.name === "hasPopup")) continue
      if (n.ignored) continue
      let visible = ""
      if (n.backendDOMNodeId) {
        const { object } = await cdp.send("DOM.resolveNode", { backendNodeId: n.backendDOMNodeId })
        const r = await cdp.send("Runtime.callFunctionOn", {
          objectId: object.objectId,
          functionDeclaration: "function(){return this.innerText.trim()}",
          returnByValue: true,
        })
        visible = r.result.value
      }
      buttons.push({ accessibleName: n.name?.value, visibleText: visible })
    }
    console.table(buttons)
    await page.screenshot({ path: "/workspace/media/composer.png" })
    await browser.close()

    Run it: bun a11y.mjs

  2. pranam-s commented on Oct 21, 2026

    @pranam-s
    Author

    Fixed in #57488.

    The trigger now names itself ": ", so the agent button announces "build: Choose agent" and the variant button "default: Choose model variant" instead of the static label. The value leads because that is the text on screen, and WCAG 2.2 SC 2.5.3 asks for the visible text to be in the accessible name.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions