- #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:
@ -59,8 +59,8 @@ describe("GitPanel (with MockGitGateway)", () => {
|
||||
renderPanel(git);
|
||||
await waitForPanel();
|
||||
|
||||
expect(screen.getByText("Staged")).toBeTruthy();
|
||||
expect(screen.getByText("Unstaged")).toBeTruthy();
|
||||
await screen.findByText("Staged");
|
||||
await screen.findByText("Unstaged");
|
||||
// src/main.rs is staged → Unstage button exists
|
||||
expect(
|
||||
screen.getByRole("button", { name: "unstage src/main.rs" }),
|
||||
|
||||
@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
LocalModelServerConfig,
|
||||
ModelArtifact,
|
||||
ModelServerCommandPreview,
|
||||
ModelSource,
|
||||
StopPolicy,
|
||||
@ -47,6 +48,35 @@ const STOP_POLICIES: { value: StopPolicy; label: string }[] = [
|
||||
{ value: "stopWhenUnused", label: "Stop when unused" },
|
||||
];
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"] as const;
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1000 && unit < units.length - 1) {
|
||||
value /= 1000;
|
||||
unit += 1;
|
||||
}
|
||||
const digits = unit === 0 || value >= 10 ? 0 : 1;
|
||||
return `${value.toFixed(digits)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function deleteArtifactConfirmation(
|
||||
server: LocalModelServerConfig,
|
||||
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
|
||||
): string {
|
||||
const size =
|
||||
artifact.sizeBytes == null ? "" : `Espace libéré : ${formatBytes(artifact.sizeBytes)}.`;
|
||||
return [
|
||||
"Supprimer le modèle téléchargé ?",
|
||||
`Serveur : ${server.name}`,
|
||||
"Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.",
|
||||
size,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export interface ModelServersPanelProps {
|
||||
/** The model-server registry view-model (from `useModelServers`). */
|
||||
vm: ModelServersViewModel;
|
||||
@ -73,6 +103,14 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||||
if (saved) setDraft(null);
|
||||
}
|
||||
|
||||
async function confirmDeleteArtifact(
|
||||
server: LocalModelServerConfig,
|
||||
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
|
||||
) {
|
||||
if (!window.confirm(deleteArtifactConfirmation(server, artifact))) return;
|
||||
await vm.deleteArtifact(server.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
aria-label="local model servers"
|
||||
@ -98,6 +136,11 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
{vm.notice && (
|
||||
<p role="status" className="text-sm text-muted">
|
||||
{vm.notice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{vm.servers.length === 0 && !draft && (
|
||||
<p className="text-xs text-muted">
|
||||
@ -107,40 +150,69 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||||
)}
|
||||
|
||||
<ul className="flex list-none flex-col gap-2 p-0">
|
||||
{vm.servers.map((server) => (
|
||||
<li
|
||||
key={server.id}
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm text-content">
|
||||
{server.name}
|
||||
</strong>
|
||||
<span className="truncate text-xs text-muted">
|
||||
{server.baseURL} · {server.servedModelName}
|
||||
{server.autoStart ? " · auto-start" : ""}
|
||||
{vm.servers.map((server) => {
|
||||
const artifact = server.artifact;
|
||||
const downloaded =
|
||||
artifact?.state === "downloaded" ? artifact : undefined;
|
||||
const deletingArtifact = vm.deletingArtifactId === server.id;
|
||||
const downloadingArtifact = artifact?.state === "downloading";
|
||||
return (
|
||||
<li
|
||||
key={server.id}
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm text-content">
|
||||
{server.name}
|
||||
</strong>
|
||||
<span className="truncate text-xs text-muted">
|
||||
{server.baseURL} · {server.servedModelName}
|
||||
{server.autoStart ? " · auto-start" : ""}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={`edit ${server.name}`}
|
||||
onClick={() => startEdit(server)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`delete ${server.name}`}
|
||||
onClick={() => void vm.remove(server.id)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
×
|
||||
</IconButton>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{downloadingArtifact && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
aria-label={`download in progress ${server.name}`}
|
||||
disabled
|
||||
>
|
||||
Téléchargement en cours
|
||||
</Button>
|
||||
)}
|
||||
{downloaded && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
aria-label={`delete downloaded model ${server.name}`}
|
||||
onClick={() => void confirmDeleteArtifact(server, downloaded)}
|
||||
loading={deletingArtifact}
|
||||
disabled={vm.busy && !deletingArtifact}
|
||||
>
|
||||
{deletingArtifact ? "Suppression..." : "Supprimer le modèle téléchargé"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={`edit ${server.name}`}
|
||||
onClick={() => startEdit(server)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`delete ${server.name}`}
|
||||
onClick={() => void vm.remove(server.id)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
×
|
||||
</IconButton>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{draft && (
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
* {@link ModelServerSelect} binding dropdown.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { act, render, renderHook, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
@ -312,6 +312,95 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
|
||||
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("deletes a downloaded managed model artifact after confirmation without deleting the server config", async () => {
|
||||
const modelServer = new MockModelServerGateway();
|
||||
await modelServer.saveModelServer({
|
||||
...SERVER,
|
||||
artifact: {
|
||||
state: "downloaded",
|
||||
path: "/cache/unsloth/Qwen3.5-9B-GGUF/model.gguf",
|
||||
sizeBytes: 1_500_000_000,
|
||||
},
|
||||
});
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderPanel(modelServer);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("delete downloaded model Local A"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(confirm).toHaveBeenCalledWith(expect.stringMatching(/1\.5 GB/));
|
||||
});
|
||||
await waitFor(async () => {
|
||||
const [server] = await modelServer.listModelServers();
|
||||
expect(server).toMatchObject({
|
||||
id: SERVER.id,
|
||||
artifact: { state: "missing" },
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText(/Modèle téléchargé supprimé/i)).toBeTruthy();
|
||||
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
|
||||
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it("does not offer model artifact deletion for local .gguf or missing managed artifacts", async () => {
|
||||
const modelServer = new MockModelServerGateway();
|
||||
await modelServer.saveModelServer({
|
||||
...SERVER,
|
||||
artifact: { state: "missing" },
|
||||
});
|
||||
await modelServer.saveModelServer({
|
||||
...SERVER,
|
||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
name: "Local file",
|
||||
modelSource: { type: "localPath", path: "/models/qwen.gguf" },
|
||||
artifact: { state: "notManaged" },
|
||||
});
|
||||
renderPanel(modelServer);
|
||||
|
||||
await screen.findByLabelText("edit Local A");
|
||||
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
|
||||
expect(screen.queryByLabelText("delete downloaded model Local file")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a disabled downloading state instead of a delete action while an artifact is in progress", async () => {
|
||||
const modelServer = new MockModelServerGateway();
|
||||
await modelServer.saveModelServer({
|
||||
...SERVER,
|
||||
artifact: { state: "downloading" },
|
||||
});
|
||||
renderPanel(modelServer);
|
||||
|
||||
const downloading = await screen.findByLabelText("download in progress Local A");
|
||||
expect((downloading as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(downloading.textContent).toContain("Téléchargement en cours");
|
||||
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a short inline error when artifact deletion is blocked", async () => {
|
||||
const modelServer = new MockModelServerGateway();
|
||||
await modelServer.saveModelServer({
|
||||
...SERVER,
|
||||
artifact: {
|
||||
state: "downloaded",
|
||||
path: "/cache/model.gguf",
|
||||
},
|
||||
});
|
||||
modelServer.markInUse(SERVER.id);
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderPanel(modelServer);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("delete downloaded model Local A"));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toMatch(
|
||||
/téléchargement en cours ou agent actif/i,
|
||||
);
|
||||
expect(screen.getByLabelText("delete downloaded model Local A")).toBeTruthy();
|
||||
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it("edits an existing server's served model name", async () => {
|
||||
const modelServer = new MockModelServerGateway();
|
||||
await modelServer.saveModelServer(SERVER);
|
||||
|
||||
@ -24,14 +24,20 @@ export interface ModelServersViewModel {
|
||||
servers: LocalModelServerConfig[];
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Last non-blocking success message, or `null`. */
|
||||
notice: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
busy: boolean;
|
||||
/** Server id whose managed artifact is currently being deleted, or `null`. */
|
||||
deletingArtifactId: string | null;
|
||||
/** Reloads the server list. */
|
||||
reload: () => Promise<void>;
|
||||
/** Creates or updates a server; returns the persisted config (or `null` on error). */
|
||||
save: (config: LocalModelServerConfig) => Promise<LocalModelServerConfig | null>;
|
||||
/** Deletes a server by id; returns `true` on success. */
|
||||
remove: (serverId: string) => Promise<boolean>;
|
||||
/** Deletes only the managed downloaded model artifact; returns `true` on success. */
|
||||
deleteArtifact: (serverId: string) => Promise<boolean>;
|
||||
/**
|
||||
* Asks the backend to build the `llama-server` command line for a draft
|
||||
* (never reconstructed client-side). Returns `null` when the draft is
|
||||
@ -62,11 +68,14 @@ export function useModelServers(): ModelServersViewModel {
|
||||
const { modelServer } = useGateways();
|
||||
const [servers, setServers] = useState<LocalModelServerConfig[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [deletingArtifactId, setDeletingArtifactId] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
setServers(await modelServer.listModelServers());
|
||||
} catch (e) {
|
||||
@ -84,6 +93,7 @@ export function useModelServers(): ModelServersViewModel {
|
||||
async (config: LocalModelServerConfig) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const saved = await modelServer.saveModelServer(config);
|
||||
setServers((prev) => {
|
||||
@ -110,6 +120,7 @@ export function useModelServers(): ModelServersViewModel {
|
||||
async (serverId: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await modelServer.deleteModelServer(serverId);
|
||||
setServers((prev) => prev.filter((s) => s.id !== serverId));
|
||||
@ -132,6 +143,37 @@ export function useModelServers(): ModelServersViewModel {
|
||||
[modelServer],
|
||||
);
|
||||
|
||||
const deleteArtifact = useCallback(
|
||||
async (serverId: string) => {
|
||||
setBusy(true);
|
||||
setDeletingArtifactId(serverId);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await modelServer.deleteModelArtifact(serverId);
|
||||
setServers(await modelServer.listModelServers());
|
||||
setNotice("Modèle téléchargé supprimé. Le serveur reste configuré.");
|
||||
return true;
|
||||
} catch (e) {
|
||||
const code = codeOf(e);
|
||||
if (code === "model_server_in_use") {
|
||||
setError("Impossible de supprimer ce modèle : téléchargement en cours ou agent actif.");
|
||||
} else if (code === "invalid") {
|
||||
setError("Aucun modèle téléchargé géré à supprimer.");
|
||||
} else if (code === "not_configured") {
|
||||
setError("Serveur introuvable.");
|
||||
} else {
|
||||
setError(describe(e));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingArtifactId(null);
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[modelServer],
|
||||
);
|
||||
|
||||
const preview = useCallback(
|
||||
async (config: LocalModelServerConfig) => {
|
||||
try {
|
||||
@ -145,7 +187,22 @@ export function useModelServers(): ModelServersViewModel {
|
||||
[modelServer],
|
||||
);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}, []);
|
||||
|
||||
return { servers, error, busy, reload, save, remove, preview, clearError };
|
||||
return {
|
||||
servers,
|
||||
error,
|
||||
notice,
|
||||
busy,
|
||||
deletingArtifactId,
|
||||
reload,
|
||||
save,
|
||||
remove,
|
||||
deleteArtifact,
|
||||
preview,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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" }));
|
||||
|
||||
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user