Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 80833a376b test(app): wait for the focus hint padding before checking overlap 2026-10-06 08:35:49 +02:00
paseo-bot[bot] e85810e295 fix(app): keep the composer focus hint off the placeholder
The web composer draws its focus shortcut hint over the input's top-right
corner. When the placeholder and the hint did not fit on one line, as in
French at a phone width or with the longer desktop placeholder, the hint
was drawn on top of the end of the placeholder. The input now reserves
the hint's width as right padding while the hint shows, so the
placeholder wraps before it.

Closes #6191
2026-10-06 08:19:29 +02:00
paseo-bot[bot] e3db7c3397 fix(opencode): agent goes idle after a v2 tool permission is denied (#6188)
* fix(opencode): agent goes idle after a v2 tool permission is denied

OpenCode 2.0.20 ends the execution when a tool permission is rejected and
reports the interruption with reason "shutdown", the same reason it uses for
an execution it will resume after a server restart. The turn tracker only
treated "shutdown" as final after a dismissed question, so a denied tool
permission left the agent running with nothing pending, and Stop waited on a
turn that could never complete.

Treat any denied request owned by this session as ending the turn when the
following interruption reports "shutdown". Denials of child-session requests
do not interrupt the parent execution, so they are excluded.

* test(opencode): denying a child session's permission keeps the v2 turn resumable

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 06:08:14 +02:00
paseo-bot[bot] fe10c40b9a fix(app): keep a hover card open when a pressed button disables itself (#6185)
Clicking Refresh on a usage card in the context window details closed the
card within the close grace, with the pointer still on it. Refresh disables
itself while the request is pending, which drops the click's focus to the
document, and the hover card treated any focus leaving the card as a reason
to close.

Only keyboard focus keeps a hover card open, so only losing keyboard focus
now closes it. Whether focus is keyboard focus is read when it arrives,
because the element losing focus no longer matches :focus-visible during
focusout.

Closes #6176

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 05:03:59 +02:00
fxlelouarnandClaude Opus 5.5 b6e8ca8974 fix(app): correct the French translation (#6004)
The French locale was machine-translated word by word, without context:
"Back" became "Dos" (the body part), CI "Checks" became "Chèques" (bank
cheques), "Starting" became "Départ", "Connecting" became "De liaison",
and many interpolation placeholders were glued to neighboring words
("{{label}}copié", "Daemonindisponible").

Review every client-owned French string against its English source and
call site, apply one glossary across the file, and use French
typography (’, …, non-breaking space before high punctuation).

Add regression tests for the reported labels and for placeholders glued
to French words.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:19:45 +02:00
12 changed files with 1449 additions and 1177 deletions
@@ -0,0 +1,113 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const APP_SETTINGS_KEY = "@paseo:app-settings";
async function useLanguage(page: Page, language: string): Promise<void> {
await page.addInitScript(
({ key, language: value }) => {
localStorage.setItem(key, JSON.stringify({ language: value }));
},
{ key: APP_SETTINGS_KEY, language },
);
}
/**
* Lays the placeholder out the way the textarea does, in a mirror element with
* the same box and font, and reports whether any of its lines reach the hint.
*/
async function placeholderOverlaps(input: Locator, hint: Locator): Promise<boolean> {
const hintBox = await hint.boundingBox();
if (!hintBox) throw new Error("Focus hint has no layout box");
return input.evaluate((element, box) => {
const textarea = element as HTMLTextAreaElement;
const style = getComputedStyle(textarea);
const rect = textarea.getBoundingClientRect();
const mirror = document.createElement("div");
for (const property of [
"boxSizing",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"fontFamily",
"fontSize",
"fontWeight",
"letterSpacing",
"lineHeight",
"textIndent",
"wordSpacing",
] as const) {
mirror.style[property] = style[property];
}
Object.assign(mirror.style, {
position: "fixed",
left: `${rect.left}px`,
top: `${rect.top}px`,
width: `${rect.width}px`,
borderStyle: "solid",
borderColor: "transparent",
whiteSpace: "pre-wrap",
overflowWrap: "break-word",
visibility: "hidden",
});
const text = document.createTextNode(textarea.placeholder);
mirror.appendChild(text);
document.body.appendChild(mirror);
const range = document.createRange();
range.selectNodeContents(text);
const lines = Array.from(range.getClientRects());
mirror.remove();
return lines.some(
(line) =>
line.right > box.x &&
line.left < box.x + box.width &&
line.bottom > box.y &&
line.top < box.y + box.height,
);
}, hintBox);
}
const FRENCH_LAYOUTS = [
{ name: "phone width", width: 390, placeholder: "Message, @fichiers, /commandes" },
{
name: "desktop width",
width: 1024,
placeholder:
"Écrivez à l’agent, mentionnez des @fichiers ou utilisez les /commandes et /skills",
},
];
for (const layout of FRENCH_LAYOUTS) {
test.describe(`French composer at ${layout.name}`, () => {
test.use({ viewport: { width: layout.width, height: 844 } });
test("the focus hint does not cover the placeholder", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-hint-",
title: "Composer focus hint",
});
try {
await useLanguage(page, "fr");
await openAgentRoute(page, agent);
const input = page.getByRole("textbox", { name: "Écrire à l’agent…" }).first();
await expect(input).toBeVisible();
await expect(input).toHaveAttribute("placeholder", layout.placeholder);
await input.evaluate((element) => (element as HTMLTextAreaElement).blur());
const hint = page.getByText(/pour saisir$/).first();
await expect(hint).toBeVisible();
// The input takes the hint's width on the render after the hint lays out.
await expect.poll(() => placeholderOverlaps(input, hint)).toBe(false);
} finally {
await agent.cleanup();
}
});
});
}
@@ -268,10 +268,16 @@ for (const theme of ["light", "dark"] as const) {
await expect(card).toBeVisible();
});
await test.step("Refresh replaces the card with the source's new report", async () => {
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
await test.step("Refresh keeps the card open and replaces it with the new report", async () => {
const refreshed = gate();
usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] });
const card = contextWindowDetails(page);
await refreshUsageCard(card, "Claude");
await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeDisabled();
// Past the card's close grace, with the pointer still on the card.
await page.waitForTimeout(500);
await expect(card).toBeVisible();
refreshed.open();
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
});
@@ -429,7 +429,7 @@ test.describe("Sidebar workspace list", () => {
}
});
test("workspace hover card stays open for a keyboard-focused row", async ({ page }) => {
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
try {
@@ -445,6 +445,11 @@ test.describe("Sidebar workspace list", () => {
await expectWorkspaceHoverCardStaysOpen(page);
await page.keyboard.press("Escape");
await expectWorkspaceHoverCardClosed(page);
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
await expectWorkspaceHoverCardOpen(page);
await page.keyboard.press("Tab");
await expectWorkspaceHoverCardClosed(page);
} finally {
await workspace.cleanup();
}
@@ -82,6 +82,8 @@ function WebHoverCard({
const contentRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible.
const keyboardFocusedRef = useRef(false);
const clearGraceTimer = useCallback(() => {
if (graceTimerRef.current) {
@@ -176,16 +178,20 @@ function WebHoverCard({
setOpen(false);
};
const focusEntered = (event: FocusEvent) => {
const target = event.target as Node;
const target = event.target as Element;
keyboardFocusedRef.current = target.matches(":focus-visible");
if (trigger.contains(target)) {
openNow();
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
clearGraceTimer();
}
};
// Only keyboard focus keeps the card open, so only losing keyboard focus closes it. A button
// that disables itself on press drops pointer focus while the pointer is still on the card.
const focusLeft = (event: FocusEvent) => {
const next = event.relatedTarget as Node | null;
if (
keyboardFocusedRef.current &&
!trigger.contains(next) &&
!(contentRef.current as unknown as HTMLElement | null)?.contains(next)
) {
+27 -13
View File
@@ -608,18 +608,14 @@ function MessageInputOverlay({
}
function FocusHint({
visible,
focusInputKeys,
label,
onLayout,
}: {
visible: boolean;
focusInputKeys: ShortcutChord | null | undefined;
label: string;
onLayout: (event: LayoutChangeEvent) => void;
}) {
const { isActiveComposer } = useComposerKeyboardScope();
if (!isActiveComposer || !visible || !focusInputKeys || !label.trim()) return null;
return (
<Text style={styles.focusHintText} pointerEvents="none">
<Text style={styles.focusHintText} pointerEvents="none" onLayout={onLayout}>
{label}
</Text>
);
@@ -654,6 +650,25 @@ interface ComposerTextSurfaceProps {
* state of this composer rather than a second one.
*/
function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement {
const { isActiveComposer } = useComposerKeyboardScope();
const [focusHintWidth, setFocusHintWidth] = useState(0);
const handleFocusHintLayout = useCallback((event: LayoutChangeEvent) => {
setFocusHintWidth(event.nativeEvent.layout.width);
}, []);
const isFocusHintShown =
isActiveComposer &&
props.focusHintVisible &&
Boolean(props.focusInputKeys) &&
props.focusHintLabel.trim().length > 0;
// The hint sits over the input's top-right corner, so the placeholder wraps
// before it instead of running underneath.
const textInputStyle = useMemo(
() =>
isFocusHintShown
? [props.textInputStyle, { paddingRight: focusHintWidth }]
: props.textInputStyle,
[focusHintWidth, isFocusHintShown, props.textInputStyle],
);
if (props.readOnly) {
return (
<View style={styles.textInputScrollWrapper}>
@@ -674,7 +689,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
accessibilityLabel={props.accessibilityLabel}
onFocus={props.onFocus}
onBlur={props.onBlur}
style={props.textInputStyle}
style={textInputStyle}
multiline
scrollEnabled={props.scrollEnabled}
editable={props.editable}
@@ -684,11 +699,9 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
onPasteError={props.onPasteError}
autoFocus={props.autoFocus}
/>
<FocusHint
visible={props.focusHintVisible}
focusInputKeys={props.focusInputKeys}
label={props.focusHintLabel}
/>
{isFocusHintShown ? (
<FocusHint label={props.focusHintLabel} onLayout={handleFocusHintLayout} />
) : null}
</View>
);
}
@@ -1955,6 +1968,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
position: "absolute",
top: 0,
right: 0,
paddingLeft: theme.spacing[2],
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
opacity: 0.5,
+17
View File
@@ -183,6 +183,23 @@ describe("translation resources", () => {
expect(ko.desktop.daemon.status.notRunning).toBe("실행 중이 아님");
});
it("uses the French navigation and Git meanings for reported labels", () => {
expect(fr.common.back).toBe("Retour");
expect(fr.common.actions.back).toBe("Retour");
expect(fr.common.actions.copy).toBe("Copier");
expect(fr.common.states.starting).toBe("Démarrage…");
expect(fr.common.connectionStatus.connecting).toBe("Connexion…");
expect(fr.workspace.git.pr.sections.checks).toBe("Vérifications");
expect(fr.sidebar.display.show.checks).toBe("Vérifications");
});
it("separates French interpolation placeholders from neighboring words", () => {
const glued = Object.entries(flattenStrings(fr)).filter(([, value]) =>
/\p{L}\{\{|\}\}\p{L}{2,}/u.test(value),
);
expect(glued).toEqual([]);
});
it("labels the immediate add-to-chat action without an ellipsis", () => {
expect(en.workspace.fileActions.addToChat).toBe("Add to chat");
});
File diff suppressed because it is too large Load Diff
@@ -163,7 +163,7 @@ export const pluginSettings = {
globalEnabled: "Plugins activés",
globalDisabled: "Plugins désactivés",
},
removeConfirmTitle: "Supprimer {{id}} ?",
removeConfirmTitle: "Supprimer {{id}} ?",
removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.",
states: {
offlineTitle: "L’hôte des plugins est hors ligne",
@@ -5,6 +5,7 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import type { V2Api } from "./api.js";
function terminalEvents(events: AgentStreamEvent[]) {
return events.filter(
@@ -266,6 +267,116 @@ describe("OpenCode v2 session lifecycle", () => {
}
});
test("ends the turn when the user denies a tool permission", async () => {
const harness = new V2Harness();
harness.autoComplete = false;
let permission: Awaited<ReturnType<V2Api["permission"]["list"]>>[number] | null = null;
harness.api.permission.list = async () => (permission ? [permission] : []);
harness.api.permission.reply = async () => {
permission = null;
harness.active = false;
// OpenCode 2.0.20 reports a denied tool call as an interruption without a user reason.
harness.push({
id: "denied",
created: 3,
type: "session.execution.interrupted",
durable: { aggregateID: "session", seq: 3, version: 1 },
data: { sessionID: "session", reason: "shutdown" },
});
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.startTurn("run printf");
await expect.poll(() => harness.prompts).toHaveLength(1);
permission = {
id: "approval",
sessionID: "session",
action: "shell",
resources: ["printf TOOL_PERMISSION_QA"],
};
harness.push({
id: "asked",
created: 2,
type: "permission.asked",
data: permission,
});
await expect.poll(() => session.getPendingPermissions()).toHaveLength(1);
await session.respondToPermission("approval", { behavior: "deny" });
await expect.poll(() => terminalEvents(events)).toHaveLength(1);
expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_canceled" });
await session.startTurn("next message");
await expect.poll(() => harness.prompts).toHaveLength(2);
harness.finishExecution();
await expect.poll(() => terminalEvents(events)).toHaveLength(2);
expect(terminalEvents(events)[1]).toMatchObject({ type: "turn_completed" });
} finally {
await session.close();
}
});
test("keeps the turn resumable after the user denies a child session's permission", async () => {
const harness = new V2Harness();
harness.autoComplete = false;
const child = { ...harness.info, id: "child", parentID: "session" };
harness.api.session.list = async (input) => ({
data: input?.parentID === "session" ? [child] : [],
cursor: {},
});
let permission: Awaited<ReturnType<V2Api["permission"]["list"]>>[number] | null = {
id: "child-approval",
sessionID: "child",
action: "shell",
resources: ["pwd"],
};
harness.api.permission.list = async (input) =>
permission && input.sessionID === "child" ? [permission] : [];
harness.api.permission.reply = async () => {
permission = null;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.startTurn("delegate work");
await expect.poll(() => harness.prompts).toHaveLength(1);
expect(session.getPendingPermissions()).toHaveLength(1);
await session.respondToPermission("child-approval", { behavior: "deny" });
harness.active = false;
harness.push({
id: "shutdown",
created: 3,
type: "session.execution.interrupted",
durable: { aggregateID: "session", seq: 3, version: 1 },
data: { sessionID: "session", reason: "shutdown" },
});
const reads = harness.activeReads;
harness.push({ id: "reconnect", created: 4, type: "server.connected", data: {} });
await expect.poll(() => harness.activeReads).toBeGreaterThan(reads);
expect(terminalEvents(events)).toEqual([]);
harness.startExecution();
harness.finishExecution();
await expect.poll(() => terminalEvents(events)).toHaveLength(1);
expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" });
} finally {
await session.close();
}
});
test("recovers the latest execution from the durable log when shutdown and resume events were missed", async () => {
const harness = new V2Harness();
harness.autoComplete = false;
@@ -33,8 +33,11 @@ export class SessionPermissions {
list() {
return [...this.pending.values()];
}
isQuestion(requestId: string, sessionID: string) {
return this.forms.get(requestId)?.sessionID === sessionID;
isOwnedBy(requestId: string, sessionID: string) {
return (
this.forms.get(requestId)?.sessionID === sessionID ||
this.permissionOwners.get(requestId) === sessionID
);
}
async respondToPermission(requestId: string, response: AgentPermissionResponse) {
const form = this.forms.get(requestId);
@@ -405,10 +405,10 @@ export class OpenCodeV2Session implements AgentSession {
}
async respondToPermission(requestId: string, response: AgentPermissionResponse) {
await this.reconnectIfExited();
const dismissesQuestion =
response.behavior === "deny" && this.permissions.isQuestion(requestId, this.id);
const deniesOwnRequest =
response.behavior === "deny" && this.permissions.isOwnedBy(requestId, this.id);
await this.permissions.respondToPermission(requestId, response);
if (dismissesQuestion) this.turns.questionDismissed();
if (deniesOwnRequest) this.turns.requestDenied();
}
private async reconcileSnapshot() {
const [info, history] = await Promise.all([
@@ -60,7 +60,7 @@ interface Turn {
completion: Promise<void>;
settle(): void;
accepted: boolean;
questionDismissed: boolean;
requestDenied: boolean;
}
export class SessionTurns {
@@ -113,7 +113,7 @@ export class SessionTurns {
settle,
accepted,
output,
questionDismissed: false,
requestDenied: false,
};
this.turn = turn;
this.execution = null;
@@ -164,11 +164,11 @@ export class SessionTurns {
this.sequence = event.durable.seq;
this.requestReconciliation();
}
// OpenCode ends the execution when the user dismisses its question, but reports the
// interruption as "shutdown", the same reason as an execution it will resume.
questionDismissed() {
// OpenCode ends the execution when the user denies its permission or dismisses its question,
// but reports the interruption as "shutdown", the same reason as an execution it will resume.
requestDenied() {
if (!this.turn) return;
this.turn.questionDismissed = true;
this.turn.requestDenied = true;
this.requestReconciliation();
}
private requestReconciliation() {
@@ -227,7 +227,7 @@ export class SessionTurns {
execution.type === "session.execution.started" ||
(execution.type === "session.execution.interrupted" &&
execution.data.reason === "shutdown" &&
!turn.questionDismissed)
!turn.requestDenied)
)
return;
// Read the final history only after observing idle. Never turn an observation transport error into an execution failure.