Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/pr-assets/1001/terminal-resize.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 35 additions & 15 deletions frontend/src/components/common/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export default function Terminal({
const [connecting, setConnecting] = React.useState(false);
const [connected, setConnected] = React.useState(false);
const pingLooper = React.useRef<number | null>(null);
const resizeFrame = React.useRef<number | null>(null);
const lastReportedSize = React.useRef('');

React.useEffect(() => {
onConnectionStatusChanged?.(connecting ? 'connecting' : connected ? 'connected' : 'disconnected');
Expand All @@ -87,19 +89,33 @@ export default function Terminal({
const handleResize = () => {
if (xtermInstance.current) {
fitAddonRef.current?.fit();
// readyState 1 means the websocket is open.
if (websocketInstance.current && websocketInstance.current.readyState === 1) {
websocketInstance.current.send(JSON.stringify({
type: "resize",
data: JSON.stringify({
row: xtermInstance.current?.rows,
col: xtermInstance.current?.cols
if (websocketInstance.current && websocketInstance.current.readyState === WebSocket.OPEN) {
const nextSize = `${xtermInstance.current.cols}x${xtermInstance.current.rows}`;
if (nextSize === lastReportedSize.current) return;

websocketInstance.current.send(
JSON.stringify({
type: "resize",
data: JSON.stringify({
row: xtermInstance.current.rows,
col: xtermInstance.current.cols
})
})
}));
);
lastReportedSize.current = nextSize;
}
}
};

const scheduleResize = () => {
if (resizeFrame.current !== null) {
cancelAnimationFrame(resizeFrame.current);
}
resizeFrame.current = requestAnimationFrame(() => {
resizeFrame.current = null;
handleResize();
});
};

React.useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
Expand All @@ -121,13 +137,17 @@ export default function Terminal({
if (!terminalDiv.current) return;

const resizeObserver = new ResizeObserver(() => {
handleResize();
scheduleResize();
});

resizeObserver.observe(terminalDiv.current);

return () => {
resizeObserver.disconnect();
if (resizeFrame.current !== null) {
cancelAnimationFrame(resizeFrame.current);
resizeFrame.current = null;
}
};
}, []);

Expand All @@ -142,6 +162,11 @@ export default function Terminal({
}, [validTheme])

const resetTerminal = () => {
if (resizeFrame.current !== null) {
cancelAnimationFrame(resizeFrame.current);
resizeFrame.current = null;
}
lastReportedSize.current = '';
if (xtermInstance.current) {
xtermInstance.current.dispose();
xtermInstance.current = null;
Expand Down Expand Up @@ -228,12 +253,7 @@ export default function Terminal({
setConnected(true);
xtermInstance.current?.focus();
// Trigger resize after the DOM has updated.
requestAnimationFrame(() => {
handleResize();
});
} else if (data.type === 'resize') {
const { col, row } = JSON.parse(data.data);
xtermInstance.current?.resize(col, row);
scheduleResize();
} else if (data.type === 'error') {
toast.error(t("common.terminal.serverError", { message: data.data }));
}
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/pages/console/user/task/task-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1671,9 +1671,9 @@ export default function TaskDetailPage() {
<TaskPreparingView task={task} />
) : (
<ResizablePanelGroup orientation="vertical">
<ResizablePanel id="top" defaultSize={hasBottomTerminal ? 75 : 100} minSize={30} className="min-h-0">
<ResizablePanel id="top" defaultSize={hasBottomTerminal ? "75%" : "100%"} minSize="30%" className="min-h-0">
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel id="chat" defaultSize={hasSidePanel ? 50 : 100} minSize={hasSidePanel ? 30 : 100} className="min-w-0">
<ResizablePanel id="chat" defaultSize={hasSidePanel ? "50%" : "100%"} minSize={hasSidePanel ? "30%" : "100%"} className="min-w-0">
<div className={cn("flex flex-col h-full min-h-0 gap-2 flex-1 min-w-0")}>
<div ref={chatScrollRootRef} className="flex-1 min-h-0 min-w-0 relative">
<ScrollArea className="h-full [&>[data-radix-scroll-area-viewport]>div]:!block">
Expand Down Expand Up @@ -1734,7 +1734,7 @@ export default function TaskDetailPage() {
{hasSidePanel && (
<>
<ResizableHandle withHandle className="ml-2 shrink-0 bg-transparent after:hidden" />
<ResizablePanel id="right-panel" defaultSize={50} minSize={25} className="min-w-0">
<ResizablePanel id="right-panel" defaultSize="50%" minSize="25%" className="min-w-0">
<div className="h-full overflow-hidden flex flex-col">
{activeSidePanel === "files" && (
<div className="flex-1 min-h-0 overflow-hidden">
Expand All @@ -1758,7 +1758,7 @@ export default function TaskDetailPage() {
{hasBottomTerminal && (
<>
<ResizableHandle withHandle className="mt-2 shrink-0 bg-transparent after:hidden" />
<ResizablePanel id="bottom-terminal" defaultSize={25} minSize={20} className="min-h-0">
<ResizablePanel id="bottom-terminal" defaultSize="25%" minSize="20%" maxSize="70%" className="min-h-0">
<div className="h-full w-full border rounded-md overflow-hidden">
<TaskTerminalPanel envid={envid} disabled={!taskInteractive} onClosePanel={() => setTerminalPanelOpen(false)} />
</div>
Expand Down
31 changes: 31 additions & 0 deletions frontend/test/task-terminal-panel-resize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";

const source = readFileSync(
new URL("../src/pages/console/user/task/task-detail.tsx", import.meta.url),
"utf8",
);

test("任务详情面板使用 react-resizable-panels v4 的显式百分比尺寸", () => {
assert.match(
source,
/id="top" defaultSize=\{hasBottomTerminal \? "75%" : "100%"\} minSize="30%"/,
);
assert.match(
source,
/id="chat" defaultSize=\{hasSidePanel \? "50%" : "100%"\} minSize=\{hasSidePanel \? "30%" : "100%"\}/,
);
assert.match(source, /id="right-panel" defaultSize="50%" minSize="25%"/);
assert.match(
source,
/id="bottom-terminal" defaultSize="25%" minSize="20%" maxSize="70%"/,
);
});

test("任务详情面板不再把百分比意图作为像素数传给 v4", () => {
assert.doesNotMatch(
source,
/(?:defaultSize|minSize|maxSize)=\{(?:20|25|30|50|75|100)\}/,
);
});
32 changes: 32 additions & 0 deletions frontend/test/terminal-resize-ownership.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";

const source = readFileSync(
new URL("../src/components/common/terminal.tsx", import.meta.url),
"utf8",
);

test("浏览器容器是终端行列数的唯一来源", () => {
const remoteResizeBranch = source.match(
/else if \(data\.type === ['"]resize['"]\) \{([\s\S]*?)\n\s*\} else if/,
);

assert.equal(remoteResizeBranch, null);
assert.doesNotMatch(source, /xtermInstance\.current\?\.resize\(col, row\)/);
});

test("容器变化在下一帧完成 fit 并上报最新行列数", () => {
assert.match(
source,
/const scheduleResize = \(\) => \{[\s\S]*requestAnimationFrame/,
);
assert.match(
source,
/new ResizeObserver\(\(\) => \{\s*scheduleResize\(\);?\s*\}\)/,
);
assert.match(
source,
/fitAddonRef\.current\?\.fit\(\)[\s\S]*row: xtermInstance\.current\.rows[\s\S]*col: xtermInstance\.current\.cols/,
);
});
Loading