fix(desktop): wheel-scroll opencode transcripts and surface native Windows notifications (#2422)
This commit is contained in:
parent
0f2295c0c0
commit
71fc914afa
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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" />);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue