Skip to content

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

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

fix(ui): keep permission dock buttons in view on long requests#33563
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

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

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.
@Arcadi4 Arcadi4 force-pushed the fix/permission-overflow branch from 362b96d to 19af937 Compare June 24, 2026 00:56
@Arcadi4

Arcadi4 commented Jun 24, 2026

Copy link
Copy Markdown
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.

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

Labels

None yet

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

1 participant