Skip to content

Commit 2356904

Browse files
committed
fix: add extension dialog keyboard navigation and countdown
Build on the collapsible dialogs from #612 with native button navigation, local Escape handling, and a display-only server deadline countdown. Add desktop and mobile browser coverage for extension dialog interactions.
1 parent cc1394d commit 2356904

7 files changed

Lines changed: 160 additions & 8 deletions

File tree

‎components/ChatWindow.tsx‎

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,7 +1113,24 @@ function ExtensionDialog({
11131113
const { t } = useI18n();
11141114
const [value, setValue] = useState(request.method === "editor" ? request.prefill ?? "" : "");
11151115
const [collapsed, setCollapsed] = useState(false);
1116+
const [now, setNow] = useState(() => Date.now());
11161117
const summary = getExtensionDialogSummary(request);
1118+
const remainingSeconds = request.expiresAt === undefined
1119+
? null
1120+
: Math.max(0, Math.ceil((request.expiresAt - now) / 1000));
1121+
1122+
useEffect(() => {
1123+
if (request.expiresAt === undefined) return;
1124+
// The server closes expired requests via extension_ui_closed.
1125+
const timer = setInterval(() => setNow(Date.now()), 1000);
1126+
return () => clearInterval(timer);
1127+
}, [request.expiresAt]);
1128+
1129+
const countdown = remainingSeconds !== null && (
1130+
<span style={{ fontSize: 11, color: "var(--text-dim)", whiteSpace: "nowrap", flexShrink: 0 }}>
1131+
{t("chat.extensionExpiresIn", { seconds: remainingSeconds })}
1132+
</span>
1133+
);
11171134

11181135
const submitValue = () => {
11191136
if (request.method === "confirm") {
@@ -1125,6 +1142,12 @@ function ExtensionDialog({
11251142

11261143
return (
11271144
<div
1145+
onKeyDown={(event) => {
1146+
if (event.key !== "Escape" || event.nativeEvent.isComposing) return;
1147+
event.preventDefault();
1148+
event.stopPropagation();
1149+
onRespond(request, { cancelled: true });
1150+
}}
11281151
style={{
11291152
position: "absolute",
11301153
inset: 0,
@@ -1169,13 +1192,15 @@ function ExtensionDialog({
11691192
{summary}
11701193
</span>
11711194
)}
1195+
{countdown}
11721196
<span style={{ fontSize: 12, color: "var(--text-muted)", flexShrink: 0 }}>
11731197
{t("chat.extensionExpand")}
11741198
</span>
11751199
</button>
11761200
) : (
11771201
<div
11781202
role="dialog"
1203+
aria-label={request.title}
11791204
style={{
11801205
pointerEvents: "auto",
11811206
width: "min(560px, 100%)",
@@ -1192,7 +1217,10 @@ function ExtensionDialog({
11921217
<div style={{ flexShrink: 0, display: "flex", alignItems: "flex-start", gap: 8, padding: "12px 14px", borderBottom: "1px solid var(--border)" }}>
11931218
<div style={{ flex: 1, minWidth: 0 }}>
11941219
<div style={{ color: "var(--text)", fontSize: 14, fontWeight: 650 }}>{request.title}</div>
1195-
<div style={{ marginTop: 3, color: "var(--text-dim)", fontSize: 11, fontFamily: "var(--font-mono)" }}>{t("chat.extensionRequest")}</div>
1220+
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 3, color: "var(--text-dim)", fontSize: 11, fontFamily: "var(--font-mono)" }}>
1221+
<span>{t("chat.extensionRequest")}</span>
1222+
{countdown}
1223+
</div>
11961224
</div>
11971225
<button
11981226
type="button"
@@ -1229,10 +1257,25 @@ function ExtensionDialog({
12291257
<div style={{ color: "var(--text-muted)", fontSize: 13, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>{request.message}</div>
12301258
)}
12311259
{request.method === "select" && (
1232-
<div style={{ display: "grid", gap: 8 }}>
1233-
{request.options.map((option) => (
1260+
<div
1261+
onKeyDown={(event) => {
1262+
if (!["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft", "Home", "End"].includes(event.key)) return;
1263+
const buttons = Array.from(event.currentTarget.querySelectorAll("button"));
1264+
const index = buttons.indexOf(event.target as HTMLButtonElement);
1265+
if (index < 0) return;
1266+
event.preventDefault();
1267+
const next = event.key === "Home" ? 0
1268+
: event.key === "End" ? buttons.length - 1
1269+
: (index + (event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length;
1270+
buttons[next].focus({ preventScroll: true });
1271+
buttons[next].scrollIntoView({ block: "nearest" });
1272+
}}
1273+
style={{ display: "grid", gap: 8 }}
1274+
>
1275+
{request.options.map((option, index) => (
12341276
<button
12351277
key={option}
1278+
autoFocus={index === 0}
12361279
onClick={() => onRespond(request, { value: option })}
12371280
style={{
12381281
width: "100%",
@@ -1259,8 +1302,7 @@ function ExtensionDialog({
12591302
placeholder={request.placeholder}
12601303
onChange={(e) => setValue(e.target.value)}
12611304
onKeyDown={(e) => {
1262-
if (e.key === "Enter") submitValue();
1263-
if (e.key === "Escape") onRespond(request, { cancelled: true });
1305+
if (e.key === "Enter" && !e.nativeEvent.isComposing) submitValue();
12641306
}}
12651307
style={{
12661308
width: "100%",
@@ -1280,8 +1322,7 @@ function ExtensionDialog({
12801322
value={value}
12811323
onChange={(e) => setValue(e.target.value)}
12821324
onKeyDown={(e) => {
1283-
if (e.key === "Escape") onRespond(request, { cancelled: true });
1284-
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") submitValue();
1325+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && !e.nativeEvent.isComposing) submitValue();
12851326
}}
12861327
style={{
12871328
width: "100%",
@@ -1303,6 +1344,7 @@ function ExtensionDialog({
13031344

13041345
<div style={{ flexShrink: 0, display: "flex", justifyContent: "flex-end", gap: 8, padding: "10px 14px", borderTop: "1px solid var(--border)", background: "var(--bg-panel)" }}>
13051346
<button
1347+
autoFocus={request.method === "confirm" || (request.method === "select" && request.options.length === 0)}
13061348
onClick={() => onRespond(request, { cancelled: true })}
13071349
style={{
13081350
padding: "6px 10px",

‎e2e/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ Coverage:
3030
- Branch context follows the selected leaf and excludes the other branch.
3131
- Markdown, code blocks, and real tool-call/tool-result blocks render.
3232
- Unknown sessions and paths outside the fixture project are rejected.
33+
- A local extension checks dialog keyboard navigation, Esc cancellation,
34+
collapse/expand draft preservation, countdown display, and server-side expiry.
3335

34-
Sending prompts, live streaming, and agent execution are outside this suite.
36+
Model prompts, live model streaming, and agent execution are outside this suite.
3537
Failures save a screenshot, Playwright trace, and server log under
3638
`test-results/e2e/`; CI uploads that directory. Open a trace with
3739
`npx playwright show-trace test-results/e2e/trace.zip`.

‎e2e/extension-dialog.mjs‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import assert from "node:assert/strict";
2+
import { join } from "node:path";
3+
4+
export const extensionSource = `export default function (pi) {
5+
pi.registerCommand("e2e-dialog", {
6+
handler: async (mode, ctx) => {
7+
let result;
8+
if (mode === "timeout") {
9+
await ctx.ui.select("E2E timeout", ["Wait"], { timeout: 4000 });
10+
result = await ctx.ui.input("E2E after timeout");
11+
} else if (mode === "select") {
12+
result = await ctx.ui.select("E2E select", Array.from({ length: 30 }, (_, i) => "Option " + (i + 1)));
13+
} else {
14+
result = await ctx.ui[mode]("E2E " + mode, "Details");
15+
}
16+
ctx.ui.notify("E2E " + mode + " result: " + String(result));
17+
},
18+
});
19+
}`;
20+
21+
export async function checkExtensionDialogs(page, artifacts, width) {
22+
const commands = [];
23+
const onRequest = (request) => {
24+
if (request.method() === "POST" && /\/api\/agent\/[^/]+$/.test(new URL(request.url()).pathname)) {
25+
commands.push(request.postDataJSON());
26+
}
27+
};
28+
page.on("request", onRequest);
29+
const start = async (mode) => {
30+
await page.mouse.move(0, 0);
31+
await page.locator("[data-minimap-preview-box]").waitFor({ state: "hidden" });
32+
const input = page.locator("textarea").last();
33+
await input.fill(`/e2e-dialog ${mode}`);
34+
await page.getByRole("button", { name: "Send", exact: true }).click();
35+
const dialog = page.getByRole("dialog", { name: `E2E ${mode}`, exact: true });
36+
await dialog.waitFor();
37+
return dialog;
38+
};
39+
const finish = async (mode, result) => {
40+
await page.getByText(`E2E ${mode} result: ${result}`, { exact: true }).waitFor();
41+
await page.getByRole("button", { name: "Stop agent", exact: true }).waitFor({ state: "hidden" });
42+
};
43+
44+
try {
45+
const select = await start("select");
46+
await page.waitForFunction(() => document.activeElement?.textContent === "Option 1");
47+
for (const [key, expected] of [["ArrowUp", "Option 30"], ["ArrowDown", "Option 1"], ["ArrowRight", "Option 2"], ["ArrowLeft", "Option 1"], ["End", "Option 30"], ["Home", "Option 1"], ["End", "Option 30"]]) {
48+
await page.keyboard.press(key);
49+
assert.equal(await page.locator(":focus").textContent(), expected);
50+
}
51+
assert.ok(await page.locator(":focus").evaluate(element => {
52+
const bounds = element.getBoundingClientRect();
53+
const container = element.parentElement.parentElement.getBoundingClientRect();
54+
return bounds.top >= container.top && bounds.bottom <= container.bottom;
55+
}), "Keyboard selection must scroll into view");
56+
await page.screenshot({ path: join(artifacts, `extension-select-${width}.png`) });
57+
await page.keyboard.press("Enter");
58+
await select.waitFor({ state: "hidden" });
59+
await finish("select", "Option 30");
60+
61+
for (const mode of ["select", "confirm", "input", "editor"]) {
62+
const dialog = await start(mode);
63+
assert.ok(await page.locator(":focus").evaluate(element => element.closest('[role="dialog"]')));
64+
if (mode === "input" || mode === "editor") {
65+
await dialog.getByRole("textbox").fill("Preserved draft");
66+
await dialog.getByRole("button", { name: "Collapse", exact: true }).click();
67+
await page.getByRole("button", { name: new RegExp(`Awaiting response.*E2E ${mode}`) }).click();
68+
assert.equal(await dialog.getByRole("textbox").inputValue(), "Preserved draft");
69+
}
70+
if (mode === "input" || mode === "editor") await dialog.getByRole("button", { name: "Cancel", exact: true }).focus();
71+
const before = commands.length;
72+
await page.keyboard.press("Escape");
73+
await dialog.waitFor({ state: "hidden" });
74+
await finish(mode, mode === "confirm" ? "false" : "undefined");
75+
assert.equal(commands.slice(before).filter(command => command.type === "extension_ui_response").length, 1);
76+
assert.equal(commands.slice(before).some(command => command.type === "abort"), false, "Dialog Esc must not abort the agent");
77+
}
78+
79+
const timed = await start("timeout");
80+
const countdown = timed.getByText(/expires in \ds/);
81+
await countdown.waitFor();
82+
const initialCountdown = await countdown.innerText();
83+
await page.waitForFunction(initial => {
84+
const text = document.querySelector('[role="dialog"]')?.textContent;
85+
return text?.includes("expires in") && !text.includes(initial);
86+
}, initialCountdown);
87+
const before = commands.length;
88+
await timed.getByRole("button", { name: "Collapse", exact: true }).click();
89+
await page.getByRole("button", { name: /Awaiting response.*E2E timeout.*expires in/ }).waitFor();
90+
const afterTimeout = page.getByRole("dialog", { name: "E2E after timeout", exact: true });
91+
await afterTimeout.waitFor();
92+
assert.equal(commands.slice(before).some(command => command.type === "extension_ui_response"), false, "Only the server closes expired requests");
93+
assert.equal(await afterTimeout.getByText(/expires in/).count(), 0);
94+
await afterTimeout.getByRole("textbox").fill("Still answerable");
95+
await page.keyboard.press("Enter");
96+
await finish("timeout", "Still answerable");
97+
console.log(`PASS: ${width}px extension keyboard navigation, cancel, draft preservation, and server expiry`);
98+
} finally {
99+
page.off("request", onRequest);
100+
}
101+
}

‎e2e/run.mjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { dirname, join } from "node:path";
99
import { setTimeout as delay } from "node:timers/promises";
1010
import { fileURLToPath } from "node:url";
1111
import { chromium } from "playwright";
12+
import { checkExtensionDialogs, extensionSource } from "./extension-dialog.mjs";
1213

1314
const root = dirname(dirname(fileURLToPath(import.meta.url)));
1415
const mode = process.env.E2E_SERVER_MODE || "dev";
@@ -56,6 +57,8 @@ process.once("SIGTERM", interrupt);
5657

5758
try {
5859
// Seed before startup so the first catalogue scan sees every fixture.
60+
mkdirSync(join(agentDir, "extensions"));
61+
writeFileSync(join(agentDir, "extensions", "e2e-dialog.js"), extensionSource);
5962
writeSession(LONG, Array.from({ length: 5000 }, (_, i) =>
6063
message(`e${i}`, i ? `e${i - 1}` : null, i % 2 ? "assistant" : "user", text(i))));
6164
writeSession(BRANCH, [
@@ -280,6 +283,7 @@ try {
280283
});
281284
await page.screenshot({ path: join(artifacts, "compaction-minimap.png") });
282285
}
286+
await checkExtensionDialogs(page, artifacts, viewport.width);
283287
assert.deepEqual(errors, [], `Browser errors at width ${viewport.width}`);
284288
console.log(`PASS: ${viewport.width}px browser pagination, branch, markdown, code, tool call, and compaction navigation`);
285289
await context.tracing.stop();

‎lib/i18n/messages/en.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ export const enLocale: LocalePlugin = {
287287
"chat.openWrittenFile": "Open {name}",
288288
"chat.loadEarlier": "Scroll up to load earlier messages",
289289
"chat.extensionRequest": "extension request",
290+
"chat.extensionExpiresIn": "expires in {seconds}s",
290291
"chat.extensionPending": "Awaiting response",
291292
"chat.extensionCollapse": "Collapse",
292293
"chat.extensionExpand": "Expand",

‎lib/i18n/messages/zh-CN.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ export const zhCNLocale: LocalePlugin = {
287287
"chat.openWrittenFile": "打开 {name}",
288288
"chat.loadEarlier": "向上滚动以加载更早的消息",
289289
"chat.extensionRequest": "扩展请求",
290+
"chat.extensionExpiresIn": "{seconds} 秒后过期",
290291
"chat.extensionPending": "待回答",
291292
"chat.extensionCollapse": "收起",
292293
"chat.extensionExpand": "展开",

‎lib/i18n/messages/zh-TW.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ export const zhTWLocale: LocalePlugin = {
287287
"chat.openWrittenFile": "開啟 {name}",
288288
"chat.loadEarlier": "向上捲動以載入較早的訊息",
289289
"chat.extensionRequest": "擴充功能請求",
290+
"chat.extensionExpiresIn": "{seconds} 秒後過期",
290291
"chat.extensionPending": "待回答",
291292
"chat.extensionCollapse": "收起",
292293
"chat.extensionExpand": "展開",

0 commit comments

Comments
 (0)