Repository navigation
Conversation
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:
|
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.
|
I tested this PR against a real-world long shell-command permission prompt similar to the issue reported in #29515. Scenario tested:
Result:
Commands/checks run:
This matches the failure mode I hit in OpenCode 1.17.15, where a long |
|
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:
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. |
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