fix: livrable tickets #70 #100 #102 — UX surface scoping

- #70: implémentation suppression modèles locaux téléchargés
- #100: correction scroll OpenCode
- #102: correction fit TUI après switch/layout
- memory note scoping UX
This commit is contained in:
2026-07-26 19:42:06 +02:00
parent 4321d048ac
commit 02603441c1
31 changed files with 1589 additions and 152 deletions

View File

@ -0,0 +1,67 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { MockTerminalGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
const terminalOptions: unknown[] = [];
vi.mock("@xterm/xterm", () => ({
Terminal: class {
readonly rows = 24;
readonly cols = 80;
constructor(options: unknown) {
terminalOptions.push(options);
}
loadAddon() {}
open() {}
onData() {
return { dispose() {} };
}
write() {}
input() {}
focus() {}
dispose() {}
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit() {}
},
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
import {
TerminalView,
TERMINAL_SCROLLBACK_LINES,
} from "./TerminalView";
describe("TerminalView scrollback", () => {
it("configures xterm with a deep scrollback for chatty OpenCode agents", async () => {
const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<TerminalView cwd="/cwd" />
</DIProvider>,
);
expect(terminalOptions[0]).toMatchObject({
scrollback: TERMINAL_SCROLLBACK_LINES,
});
expect(TERMINAL_SCROLLBACK_LINES).toBeGreaterThan(1_000);
});
});

View File

@ -434,6 +434,27 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
fitSpy.mockRestore();
});
it("refits after window restore/focus without a new refitSignal", async () => {
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
const open = vi.fn(async () => makeHandle({ sessionId: "restore-1" }));
renderView(new MockTerminalGateway(), "/cwd", {
open,
refitSignal: 1,
});
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
setTerminalBoxSize(400, 200);
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
fitSpy.mockClear();
window.dispatchEvent(new Event("focus"));
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
expect(open).toHaveBeenCalledTimes(1);
fitSpy.mockRestore();
});
it("does not refit when refitSignal is left undefined (no-op for callers that don't pass it)", async () => {
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
const open = vi.fn(async () => makeHandle({ sessionId: "no-signal-1" }));

View File

@ -39,6 +39,7 @@ import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import { useGateways } from "@/app/di";
import type { ResolvedAgentSystemPermissions } from "@/domain";
import type {
OpenTerminalOptions,
ReattachResult,
@ -46,6 +47,12 @@ import type {
WritePortal,
} from "@/ports";
// The backend PTY retains a bounded byte tail for reattach (~100 KB today), but
// xterm also has its own viewport history. Its default is too shallow for chatty
// OpenCode TUIs, which made the visible cell stop scrolling long before the
// retained terminal output was exhausted.
export const TERMINAL_SCROLLBACK_LINES = 10_000;
interface TerminalViewProps {
/** Working directory the shell opens in (typically the project root). */
cwd: string;
@ -113,6 +120,8 @@ interface TerminalViewProps {
* it never remounts/reopens the terminal.
*/
refitSignal?: number;
/** Optional resolved system permissions for this agent/cell. */
systemPermissions?: ResolvedAgentSystemPermissions | null;
}
/**
@ -144,6 +153,7 @@ export function TerminalView({
portal,
onReady,
refitSignal,
systemPermissions,
}: TerminalViewProps) {
const { terminal } = useGateways();
const containerRef = useRef<HTMLDivElement | null>(null);
@ -181,7 +191,7 @@ export function TerminalView({
// Holds the mounted instance's `refit` closure so the `refitSignal` effect
// below (a separate effect, since it must NOT re-run/reopen the terminal on
// every parent render) can trigger it without depending on `cwd`'s effect.
const refitRef = useRef<(() => void) | null>(null);
const refitRef = useRef<((settleFrames?: number) => void) | null>(null);
useEffect(() => {
const container = containerRef.current;
@ -200,6 +210,7 @@ export function TerminalView({
fontSize: 13,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
scrollback: TERMINAL_SCROLLBACK_LINES,
});
const fit = new FitAddon();
term.loadAddon(fit);
@ -218,6 +229,7 @@ export function TerminalView({
let lastRows = term.rows;
let lastCols = term.cols;
let hasUsefulFit = false;
let settleFramesRemaining = 0;
// Keystroke → PTY path. The agent cell is a **native terminal**
// (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell.
@ -349,6 +361,13 @@ export function TerminalView({
// now reschedules on the next few frames instead of abandoning — bounded,
// so a container that is genuinely never laid out (e.g. headless tests)
// doesn't spin forever.
// A successful fit can also land on a non-zero but still intermediate box
// during project/layout switches, split/merge commits, re-attach, and OS
// minimize/restore. Keep a small coalesced tail of fits on following frames
// so the final settled geometry is pushed automatically without requiring a
// manual resize. This stays bounded and preserves the rows/cols-changed
// guard before touching the PTY.
const SETTLE_REFIT_FRAMES = 4;
const MAX_ZERO_SIZE_RETRIES = 8;
let zeroSizeRetries = 0;
const refit = () => {
@ -382,14 +401,29 @@ export function TerminalView({
} else if (isFirstUsefulFit) {
resizeHandleToCurrentGeometry();
}
if (settleFramesRemaining > 0) {
settleFramesRemaining -= 1;
rafId = requestAnimationFrame(refit);
}
};
const scheduleRefit = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(refit);
const scheduleRefit = (settleFrames = 0) => {
settleFramesRemaining = Math.max(settleFramesRemaining, settleFrames);
if (!rafId) rafId = requestAnimationFrame(refit);
};
const ro = new ResizeObserver(scheduleRefit);
const scheduleSettledRefit = () => scheduleRefit(SETTLE_REFIT_FRAMES);
const scheduleVisibleRefit = () => {
if (document.visibilityState === "hidden") return;
scheduleSettledRefit();
};
const ro = new ResizeObserver(() => scheduleRefit());
ro.observe(container);
scheduleRefit();
scheduleSettledRefit();
window.addEventListener("resize", scheduleSettledRefit);
window.addEventListener("focus", scheduleSettledRefit);
window.addEventListener("pageshow", scheduleSettledRefit);
document.addEventListener("visibilitychange", scheduleVisibleRefit);
window.visualViewport?.addEventListener("resize", scheduleSettledRefit);
// Let the `refitSignal` effect below trigger the SAME coalesced refit after
// a structural layout mutation (split/merge, ticket #61) — surviving cells
// don't always get a timely useful ResizeObserver event from a sibling
@ -401,6 +435,11 @@ export function TerminalView({
refitRef.current = null;
if (rafId) cancelAnimationFrame(rafId);
ro.disconnect();
window.removeEventListener("resize", scheduleSettledRefit);
window.removeEventListener("focus", scheduleSettledRefit);
window.removeEventListener("pageshow", scheduleSettledRefit);
document.removeEventListener("visibilitychange", scheduleVisibleRefit);
window.visualViewport?.removeEventListener("resize", scheduleSettledRefit);
onKey.dispose();
portalRef.current?.unbindHandle();
// DETACH, never close: tearing the view down (navigation / layout change)
@ -425,9 +464,18 @@ export function TerminalView({
// logic.
useEffect(() => {
if (refitSignal === undefined) return;
refitRef.current?.();
refitRef.current?.(4);
}, [refitSignal]);
const showNetworkBanner =
systemPermissions != null &&
(systemPermissions.runtimeLock.state === "locked" ||
systemPermissions.effective === "deny");
const networkReason =
systemPermissions?.runtimeLock.reason ??
systemPermissions?.control.reason ??
"Le réseau est interdit pour cette cellule.";
return (
<div
data-testid="terminal-view"
@ -450,6 +498,34 @@ export function TerminalView({
visibility: terminalReady ? "visible" : "hidden",
}}
/>
{showNetworkBanner && (
<div
role="status"
data-testid="terminal-network-banner"
style={{
position: "absolute",
top: 8,
left: 8,
right: 8,
padding: "0.5rem 0.75rem",
border: "1px solid rgba(245, 158, 11, 0.45)",
borderRadius: 6,
background: "rgba(24, 24, 27, 0.94)",
color: "var(--color-warning, #f59e0b)",
fontSize: 12,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
whiteSpace: "pre-wrap",
wordBreak: "break-word",
zIndex: 2,
}}
>
{systemPermissions.runtimeLock.state === "locked"
? "Réseau verrouillé par le runtime."
: "Réseau interdit pour cet agent."}{" "}
{networkReason}
</div>
)}
{!terminalReady && !openError && (
<div
data-testid="terminal-boot-placeholder"