fix(desktop): wheel-scroll opencode transcripts and surface native Windows notifications (#2422)

This commit is contained in:
Apoorv Singh 2026-07-06 01:56:14 +05:30 committed by GitHub
parent 0f2295c0c0
commit 71fc914afa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 89 additions and 2 deletions

View File

@ -70,6 +70,14 @@ process.stderr.on("error", ignoreStdStreamError);
// Must run before app ready so the About panel and default-menu role labels use it. // Must run before app ready so the About panel and default-menu role labels use it.
app.setName("Agent Orchestrator"); app.setName("Agent Orchestrator");
// Windows shows native toasts only when the app declares an AppUserModelID that
// matches its installer shortcut (the NSIS maker's appId). Without it,
// Notification.isSupported() still returns true but show() silently drops the
// toast, so notifications never appear. No-op on macOS/Linux.
if (process.platform === "win32") {
app.setAppUserModelId("dev.agent-orchestrator.desktop");
}
// Pin ALL Electron-owned state (Chromium cache, cookies, local/session storage, // Pin ALL Electron-owned state (Chromium cache, cookies, local/session storage,
// crash dumps) under the canonical AO home at ~/.ao instead of Electron's macOS // crash dumps) under the canonical AO home at ~/.ao instead of Electron's macOS
// default ~/Library/Application Support/<name>. Keeps the app's entire footprint // default ~/Library/Application Support/<name>. Keeps the app's entire footprint

View File

@ -52,6 +52,11 @@ export function TerminalPane({ session, theme, daemonReady, terminalTarget, font
); );
} }
// Agents whose full-screen TUI keeps its own transcript and scrolls it only by
// keyboard, ignoring SGR wheel reports. The terminal routes the wheel to
// PageUp/PageDown for these (see XtermTerminal's paneScrollsByKeyboard).
const KEYBOARD_SCROLL_PROVIDERS = new Set(["opencode"]);
function bannerText(state: TerminalSessionState, error?: string): string | undefined { function bannerText(state: TerminalSessionState, error?: string): string | undefined {
if (state === "reattaching") return "Terminal disconnected — reattaching…"; if (state === "reattaching") return "Terminal disconnected — reattaching…";
if (state === "error") return `Terminal error: ${error ?? "connection failed"}`; if (state === "error") return `Terminal error: ${error ?? "connection failed"}`;
@ -74,6 +79,7 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { attach, state, error } = useTerminalSession(attachSession, { daemonReady }); const { attach, state, error } = useTerminalSession(attachSession, { daemonReady });
const handleId = attachSession?.terminalHandleId; const handleId = attachSession?.terminalHandleId;
const provider = terminalTarget?.kind === "reviewer" ? terminalTarget.harness : session?.provider;
const hadAttachmentRef = useRef(false); const hadAttachmentRef = useRef(false);
const canRestoreSession = terminalTarget?.kind !== "reviewer" && session?.status === "terminated"; const canRestoreSession = terminalTarget?.kind !== "reviewer" && session?.status === "terminated";
@ -154,6 +160,7 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz
fontSize={fontSize} fontSize={fontSize}
onError={handleInitError} onError={handleInitError}
onReady={handleReady} onReady={handleReady}
paneScrollsByKeyboard={provider ? KEYBOARD_SCROLL_PROVIDERS.has(provider) : false}
theme={theme} theme={theme}
/> />
{showEmptyState && ( {showEmptyState && (

View File

@ -9,7 +9,7 @@ const state = vi.hoisted(() => ({
wheelHandler?: (event: WheelEvent) => boolean; wheelHandler?: (event: WheelEvent) => boolean;
selection: string; selection: string;
options: Record<string, unknown>; options: Record<string, unknown>;
modes: { bracketedPasteMode: boolean }; modes: { bracketedPasteMode: boolean; mouseTrackingMode: string };
dataListeners: Set<(data: string) => void>; dataListeners: Set<(data: string) => void>;
keyListeners: Set<(event: { key: string }) => void>; keyListeners: Set<(event: { key: string }) => void>;
selectionListeners: Set<() => void>; selectionListeners: Set<() => void>;
@ -31,7 +31,7 @@ vi.mock("@xterm/xterm", () => ({
selection = ""; selection = "";
keyHandler?: (event: KeyboardEvent) => boolean; keyHandler?: (event: KeyboardEvent) => boolean;
wheelHandler?: (event: WheelEvent) => boolean; wheelHandler?: (event: WheelEvent) => boolean;
modes = { bracketedPasteMode: false }; modes = { bracketedPasteMode: false, mouseTrackingMode: "vt200" };
dataListeners = new Set<(data: string) => void>(); dataListeners = new Set<(data: string) => void>();
keyListeners = new Set<(event: { key: string }) => void>(); keyListeners = new Set<(event: { key: string }) => void>();
selectionListeners = new Set<() => void>(); selectionListeners = new Set<() => void>();
@ -492,6 +492,46 @@ describe("XtermTerminal", () => {
expect(onInput).not.toHaveBeenCalled(); expect(onInput).not.toHaveBeenCalled();
}); });
it("sends PageUp/PageDown instead of SGR reports when the pane app has mouse tracking off", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
state.lastTerminal!.modes.mouseTrackingMode = "none";
// A keyboard-scroll TUI: one page key per notch regardless of line count,
// so 3 lines up => a single PageUp.
expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");
expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[6~", "wheel");
});
it("sends PageUp/PageDown on Windows even when the pane app tracks the mouse (conpty, no mux)", () => {
setNavigatorPlatform("Win32");
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
// opencode enables full mouse tracking but scrolls its transcript only by
// keyboard; with no mux to consume SGR reports, Windows must use page keys.
state.lastTerminal!.modes.mouseTrackingMode = "any";
expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");
expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[6~", "wheel");
});
it("sends PageUp/PageDown for keyboard-scroll panes even under a mux (opencode on macOS/Linux)", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" paneScrollsByKeyboard onReady={(terminal) => terminal.onUserInput(onInput)} />);
// Linux (beforeEach) + mouse tracking on: without the paneScrollsByKeyboard
// hint this would send SGR reports; the hint forces page keys.
state.lastTerminal!.modes.mouseTrackingMode = "any";
expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");
});
it("opens terminal links via window.open so Electron routes them to the OS browser", () => { it("opens terminal links via window.open so Electron routes them to the OS browser", () => {
const open = vi.spyOn(window, "open").mockReturnValue(null); const open = vi.spyOn(window, "open").mockReturnValue(null);
render(<XtermTerminal theme="dark" />); render(<XtermTerminal theme="dark" />);

View File

@ -37,6 +37,13 @@ export type XtermTerminalProps = {
className?: string; className?: string;
fontSize?: number; fontSize?: number;
theme: Theme; theme: Theme;
/**
* The pane app scrolls its transcript by keyboard (PageUp/PageDown) rather
* than acting on SGR wheel reports e.g. opencode, which enables mouse
* tracking but never scrolls on wheel reports. Routes the wheel to page keys
* on every platform (see the wheel handler), fixing it under a mux too.
*/
paneScrollsByKeyboard?: boolean;
/** Terminal construction failed; the owner decides how to surface it. */ /** Terminal construction failed; the owner decides how to surface it. */
onError?: (error: unknown) => void; onError?: (error: unknown) => void;
/** /**
@ -176,6 +183,16 @@ function sgrWheelReport(button: number, count: number): string {
return `\x1b[<${button};1;1M`.repeat(count); return `\x1b[<${button};1;1M`.repeat(count);
} }
// PageUp (CSI 5~) / PageDown (CSI 6~) for pane apps that scroll their transcript
// by keyboard rather than mouse reports. One page key per wheel notch: a page
// already scrolls a full screen, so scaling by line count would over-scroll.
const PAGE_UP = "\x1b[5~";
const PAGE_DOWN = "\x1b[6~";
function pageKeyReport(lines: number): string {
return lines < 0 ? PAGE_UP : PAGE_DOWN;
}
function forceSelectionMode(term: Terminal): void { function forceSelectionMode(term: Terminal): void {
const internal = term as XtermInternal; const internal = term as XtermInternal;
const selectionService = internal._core?._selectionService; const selectionService = internal._core?._selectionService;
@ -486,6 +503,21 @@ export function XtermTerminal(props: XtermTerminalProps) {
wheelAccumPx -= lines * rowHeight; wheelAccumPx -= lines * rowHeight;
} }
if (lines === 0) return false; if (lines === 0) return false;
// The SGR wheel path exists to drive tmux/zellij copy-mode on
// macOS/Linux. It cannot scroll a full-screen TUI that keeps its own
// transcript and only scrolls on PageUp/PageDown (opencode): the report
// is either consumed by the mux or handed to an app that ignores it.
// Send page keys for such apps (paneScrollsByKeyboard), on Windows
// (conpty has no mux, so SGR reaches the app and is ignored), and for
// any pane app with mouse tracking fully off.
if (
callbacksRef.current.paneScrollsByKeyboard ||
isWindowsPlatform() ||
term.modes.mouseTrackingMode === "none"
) {
emitUserInput(pageKeyReport(lines), "wheel");
return false;
}
const button = lines < 0 ? SGR_WHEEL_UP : SGR_WHEEL_DOWN; const button = lines < 0 ? SGR_WHEEL_UP : SGR_WHEEL_DOWN;
emitUserInput(sgrWheelReport(button, Math.abs(lines)), "wheel"); emitUserInput(sgrWheelReport(button, Math.abs(lines)), "wheel");
return false; return false;