Skip to content

fix(ui): keep permission dock buttons in view on long requests - #33563

Closed
Arcadi4 wants to merge 1 commit into
anomalyco:devfrom
Arcadi4:fix/permission-overflow
Closed

Arcadi4 wants to merge 1 commit into
anomalyco:devfrom
Arcadi4:fix/permission-overflow

Conversation

@Arcadi4

@Arcadi4 Arcadi4 commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

Issue for this PR

Closes #28979 #33575 #29515
Re-opening of #29004

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

A long permission.patterns could push the permission dock past its slot under the session header. The page's overflow: hidden then clipped the action buttons off-screen.

The fix:

  1. Cap the permission dock to 40dvh while still accounting for the sticky session header, so the panel stays compact and the action buttons remain visible.
  2. Constrain the row that contains permission patterns so the existing overflow-y: auto can actually create an internal scroll area instead of letting long patterns grow the dock.
  3. overscroll-behavior: contain + bottom-only mask-image to prevent the scroll from bubbling to the page, and a 24px fade at the bottom edge signals more content below.

How did you verify your code works?

Reproduced the bug with a Playwright fixture that injects a PermissionV1.Request with 40 long bash patterns into a mock-server-backed session (uses the mockOpenCodeServer pattern from packages/app/e2e/utils/mock-server.ts). See the verification script in the comment below.

Screenshots / recordings

The content body is scrollable.

PixPin_2026-06-23_20-28-01

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@Arcadi4

Arcadi4 commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Verification script

Copy this script into packages/app/verify-permission-dock.mjs, then run it from packages/app:

bun ./verify-permission-dock.mjs

It starts the app dev server on http://localhost:4444, opens a headed Chromium window, and mocks the opencode API with a long PermissionV1.Request.

The script
import { chromium } from "@playwright/test";
import { spawn } from "node:child_process";

const appPort = 4444;
const apiPort = 4096;
const directory = "/tmp/perm-verify-project";
const projectID = "proj_perm_verify";
const sessionID = "ses_perm_verify";

const encodePath = (value) =>
  Buffer.from(value)
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=/g, "");

const json = (route, body, headers = {}) =>
  route.fulfill({
    status: 200,
    contentType: "application/json",
    headers: {
      "access-control-allow-origin": "*",
      "access-control-expose-headers": "x-next-cursor",
      ...headers,
    },
    body: JSON.stringify(body ?? null),
  });

const project = {
  id: projectID,
  worktree: directory,
  vcs: "git",
  name: "perm-verify",
  time: { created: 1700000000000, updated: 1700000000000 },
  sandboxes: [],
};

const provider = {
  all: [
    {
      id: "opencode",
      name: "OpenCode",
      models: {
        "claude-opus-4-6": {
          id: "claude-opus-4-6",
          name: "Claude Opus 4.6",
          limit: { context: 200_000 },
        },
      },
    },
  ],
  connected: ["opencode"],
  default: { providerID: "opencode", modelID: "claude-opus-4-6" },
};

const sessions = [
  {
    id: sessionID,
    slug: "perm-verify",
    projectID,
    directory,
    title: "Permission dock verification",
    version: "dev",
    time: { created: 1700000000000, updated: 1700000000000 },
  },
];

const longPatterns = Array.from(
  { length: 40 },
  (_, index) => `pattern-${index}-${"long-permission-pattern-".repeat(16)}.txt`
);

const permissionRequest = {
  id: "per_perm_verify_0001",
  sessionID,
  permission: "bash",
  patterns: longPatterns,
  metadata: {},
  always: [],
  tool: { messageID: "msg_assistant_0001", callID: "call_0001" },
};

const messages = [
  {
    info: {
      id: "msg_user_0001",
      sessionID,
      role: "user",
      time: { created: 1700000000000 },
      summary: { diffs: [] },
      agent: "build",
      model: {
        providerID: "opencode",
        modelID: "claude-opus-4-6",
        variant: "max",
      },
    },
    parts: [
      {
        id: "prt_user_text_0001",
        sessionID,
        messageID: "msg_user_0001",
        type: "text",
        text: "Run the command that needs permission.",
      },
    ],
  },
  {
    info: {
      id: "msg_assistant_0001",
      sessionID,
      role: "assistant",
      time: { created: 1700000001000, completed: 1700000008000 },
      parentID: "msg_user_0001",
      modelID: "claude-opus-4-6",
      providerID: "opencode",
      mode: "build",
      agent: "build",
      path: { cwd: directory, root: directory },
      cost: 0.01,
      tokens: {
        input: 100,
        output: 200,
        reasoning: 0,
        cache: { read: 0, write: 0 },
      },
      variant: "max",
      finish: "stop",
    },
    parts: [
      {
        id: "prt_tool_bash_0001",
        sessionID,
        messageID: "msg_assistant_0001",
        type: "tool",
        callID: "call_0001",
        tool: "bash",
        state: {
          status: "completed",
          input: { command: "bun typecheck" },
          output: "ok",
          title: "bun typecheck",
          metadata: {},
          time: { start: 1700000001000, end: 1700000001400 },
        },
      },
    ],
  },
];

const app = spawn(
  "bun",
  ["dev", "--", "--host", "0.0.0.0", "--port", String(appPort)],
  {
    cwd: process.cwd(),
    stdio: "inherit",
    env: {
      ...process.env,
      VITE_OPENCODE_SERVER_HOST: "127.0.0.1",
      VITE_OPENCODE_SERVER_PORT: String(apiPort),
    },
  }
);

const waitForApp = async () => {
  for (let attempt = 0; attempt < 120; attempt++) {
    try {
      const response = await fetch(`http://127.0.0.1:${appPort}`);
      if (response.ok) return;
    } catch {}
    await new Promise((resolve) => setTimeout(resolve, 500));
  }
  throw new Error(`Timed out waiting for app on :${appPort}`);
};

try {
  await waitForApp();

  const browser = await chromium.launch({ headless: false });
  const context = await browser.newContext({
    viewport: { width: 1200, height: 762 },
  });

  await context.addInitScript(
    ({ directory }) => {
      localStorage.setItem(
        "settings.v3",
        JSON.stringify({
          general: {
            editToolPartsExpanded: true,
            shellToolPartsExpanded: true,
          },
        })
      );
      localStorage.setItem(
        "opencode.global.dat:server",
        JSON.stringify({
          projects: { local: [{ worktree: directory, expanded: true }] },
          lastProject: { local: directory },
        })
      );
    },
    { directory }
  );

  await context.route("**/*", async (route) => {
    const url = new URL(route.request().url());
    if (url.port !== String(apiPort)) return route.fallback();

    const emptyList = new Set([
      "/skill",
      "/command",
      "/lsp",
      "/formatter",
      "/question",
      "/vcs/status",
      "/vcs/diff",
    ]);
    const emptyObject = new Set([
      "/global/config",
      "/config",
      "/provider/auth",
      "/mcp",
      "/session/status",
    ]);
    if (url.pathname === "/global/event" || url.pathname === "/event")
      return json(route, null);
    if (url.pathname === "/global/health")
      return json(route, { healthy: true });
    if (emptyObject.has(url.pathname)) return json(route, {});
    if (emptyList.has(url.pathname)) return json(route, []);

    const staticRoutes = {
      "/provider": provider,
      "/path": {
        state: directory,
        config: directory,
        worktree: directory,
        directory,
        home: "/tmp",
      },
      "/project": [project],
      "/project/current": project,
      "/agent": [{ name: "build", mode: "primary" }],
      "/vcs": { branch: "main", default_branch: "main" },
      "/session": sessions,
      "/permission": [permissionRequest],
    };
    if (url.pathname in staticRoutes)
      return json(route, staticRoutes[url.pathname]);

    const sessionMatch = url.pathname.match(/^\/session\/([^/]+)$/);
    if (sessionMatch)
      return json(
        route,
        sessions.find((session) => session.id === sessionMatch[1]) ?? {}
      );
    if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname))
      return json(route, []);
    if (/^\/session\/[^/]+\/message$/.test(url.pathname))
      return json(route, messages);

    return json(route, {});
  });

  const page = await context.newPage();
  await page.goto(
    `http://127.0.0.1:${appPort}/${encodePath(directory)}/session/${sessionID}`
  );
  await page.waitForSelector(
    '[data-component="dock-prompt"][data-kind="permission"]'
  );

  await new Promise((resolve) => process.stdin.once("data", resolve));
  await browser.close();
} finally {
  app.kill("SIGTERM");
}

Expected behavior:

  • The dock should be about 40% of the viewport height, not full-screen.
  • The long patterns list should scroll inside the dock.
  • The action buttons should remain visible without scrolling the whole page.
  • The bottom edge of the patterns list should have a subtle fade.

Long permission.patterns push the dock footer buttons off-screen.
Cap the dock with a viewport-relative max-height that also reserves
the inherited sticky-header offset, and constrain the patterns grid
row with min-height: 0 / minmax(0, 1fr) so overflow-y: auto engages
instead of growing the wrapper. A bottom-only mask fades the scroll
edge; no JavaScript measurement or runtime resize observers needed.
@chncaesar

Copy link
Copy Markdown

I tested this PR against a real-world long shell-command permission prompt similar to the issue reported in #29515.

Scenario tested:

  • App E2E mock server with a pending bash permission request
  • patterns contained a long SQL migration shell command:
    cat > /work/code/data-platform/migrations/016_supply_chain_dashboard.up.sql <<'SQL' ...
  • The command body included 80 long SQL/view-definition lines
  • Viewport: 1280x720
  • Assertion: Deny, Allow always, and Allow once remain visible and within the viewport

Result:

  • With the PR CSS applied: the focused Playwright probe passed.
  • After temporarily reverting this PR's CSS changes, the same probe failed:
    the footer action buttons extended past the viewport, with the action footer bottom at 756px in a 720px viewport.
  • Restoring the PR CSS made the same probe pass again.

Commands/checks run:

  • bun install
  • bun typecheck in packages/session-ui
  • bun typecheck in packages/app
  • bun test:e2e e2e/regression/session-request-docks.spec.ts
  • Focused temporary Playwright probe for the long shell-command permission prompt

This matches the failure mode I hit in OpenCode 1.17.15, where a long Run shell commands permission prompt filled the screen and pushed the allow/deny buttons out of view. Based on this verification, this PR appears to fix that case.

@github-actions

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Cannot click the permission button when the request is SUPER FREAKING LONG

2 participants