fix(ui): keep permission dock buttons in view on long requests#33563
Open
Arcadi4 wants to merge 1 commit into
Open
fix(ui): keep permission dock buttons in view on long requests#33563Arcadi4 wants to merge 1 commit into
Arcadi4 wants to merge 1 commit into
Conversation
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.
362b96d to
19af937
Compare
Author
Verification scriptCopy this script into bun ./verify-permission-dock.mjsIt starts the app dev server on The scriptimport { 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:
|
This was referenced Jun 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue for this PR
Closes #28979 #33575 #29515
Re-opening of #29004
Type of change
What does this PR do?
A long
permission.patternscould push the permission dock past its slot under the session header. The page'soverflow: hiddenthen clipped the action buttons off-screen.The fix:
40dvhwhile still accounting for the sticky session header, so the panel stays compact and the action buttons remain visible.overflow-y: autocan actually create an internal scroll area instead of letting long patterns grow the dock.overscroll-behavior: contain+ bottom-onlymask-imageto 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.Requestwith 40 longbashpatterns into a mock-server-backed session (uses themockOpenCodeServerpattern frompackages/app/e2e/utils/mock-server.ts). See the verification script in the comment below.Screenshots / recordings
The content body is scrollable.
Checklist