mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 06:01:16 +00:00
feat(control-ui): show terminal upload progress (#107789)
* feat(control-ui): add terminal upload recovery * fix(control-ui): harden terminal upload recovery
This commit is contained in:
committed by
GitHub
parent
21ec1546e5
commit
520e2580cb
@@ -319,7 +319,7 @@ The terminal is an unconfined host shell and inherits the Gateway process enviro
|
||||
|
||||
Use **Ctrl + backtick** to toggle the dock. The layout supports bottom and right docking, resizes with the browser viewport, and keeps multiple shell tabs. See [Gateway configuration](/gateway/configuration-reference#gateway) for `gateway.terminal.enabled` and the optional `gateway.terminal.shell` override.
|
||||
|
||||
Drag one or more files onto the active terminal, or use the paperclip button to choose files. OpenClaw stages each file on the machine that owns the PTY and pastes shell-quoted absolute paths at the cursor; it never presses Enter or executes the input. Images, PDFs, archives, and other file types are accepted up to 16 MiB per file. Staged files use a private system-temporary directory on POSIX hosts (directory mode `0700`, file mode `0600`) or a directory under the user-profile ACL boundary on Windows, plus a 24-hour cleanup timer, so move or copy anything you need to keep.
|
||||
Drag one or more files onto the active terminal, or use the paperclip button to choose files. OpenClaw stages each file on the machine that owns the PTY and pastes shell-quoted absolute paths at the cursor; it never presses Enter or executes the input. A compact batch indicator shows the current file and completed count. Cancel stops the remaining batch without pasting paths; a failed transfer stays visible so you can retry from that file without re-uploading completed files. Images, PDFs, archives, and other file types are accepted up to 16 MiB per file. Staged files use a private system-temporary directory on POSIX hosts (directory mode `0700`, file mode `0600`) or a directory under the user-profile ACL boundary on Windows, plus a 24-hour cleanup timer, so move or copy anything you need to keep.
|
||||
|
||||
Path insertion supports PowerShell, `cmd.exe`, and recognized POSIX shells (`sh`, Bash, Dash, Ash, Ksh, Zsh, and Fish), including Git Bash on Windows. Other shell overrides are refused because their quoting rules cannot be inferred safely; run the Gateway inside WSL for a native WSL terminal and Linux upload paths. `cmd.exe` paths containing `%` or `!` are also refused because that shell expands those characters even inside double quotes.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { BoundedBuffer } from "../../../../src/shared/bounded-buffer.ts";
|
||||
|
||||
type TerminalRequestOptions = { timeoutMs?: number | null };
|
||||
type TerminalRequestOptions = { timeoutMs?: number | null; signal?: AbortSignal };
|
||||
|
||||
/** Minimal gateway surface the terminal needs; GatewayBrowserClient satisfies it. */
|
||||
export interface TerminalGatewayClient {
|
||||
|
||||
@@ -11,21 +11,28 @@ const MAX_TERMINAL_UPLOAD_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
describe("terminal file upload", () => {
|
||||
it("requests terminal.upload with the session-bound payload", async () => {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const requests: Array<{ method: string; params: unknown; signal?: AbortSignal }> = [];
|
||||
const abortController = new AbortController();
|
||||
const client = {
|
||||
request: async <T>(method: string, params?: unknown) => {
|
||||
requests.push({ method, params });
|
||||
request: async <T>(method: string, params?: unknown, options?: { signal?: AbortSignal }) => {
|
||||
requests.push({ method, params, signal: options?.signal });
|
||||
return { path: "/tmp/scan.pdf", size: 1 } as T;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
uploadTerminalFile(client, "s1", { name: "scan.pdf", contentBase64: "AA==" }),
|
||||
uploadTerminalFile(
|
||||
client,
|
||||
"s1",
|
||||
{ name: "scan.pdf", contentBase64: "AA==" },
|
||||
abortController.signal,
|
||||
),
|
||||
).resolves.toEqual({ path: "/tmp/scan.pdf", size: 1 });
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
method: "terminal.upload",
|
||||
params: { sessionId: "s1", name: "scan.pdf", contentBase64: "AA==" },
|
||||
signal: abortController.signal,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -6,15 +6,23 @@ type TerminalUploadFile = { name: string; contentBase64: string };
|
||||
type TerminalUploadResult = { path: string; size: number };
|
||||
|
||||
type TerminalUploadClient = {
|
||||
request<T = unknown>(method: string, params?: unknown): Promise<T>;
|
||||
request<T = unknown>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<T>;
|
||||
};
|
||||
|
||||
export async function uploadTerminalFile(
|
||||
client: TerminalUploadClient,
|
||||
sessionId: string,
|
||||
file: TerminalUploadFile,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TerminalUploadResult> {
|
||||
return await client.request<TerminalUploadResult>("terminal.upload", { sessionId, ...file });
|
||||
const params = { sessionId, ...file };
|
||||
return await (signal
|
||||
? client.request<TerminalUploadResult>("terminal.upload", params, { signal })
|
||||
: client.request<TerminalUploadResult>("terminal.upload", params));
|
||||
}
|
||||
|
||||
export async function encodeTerminalUpload(file: File): Promise<string> {
|
||||
|
||||
142
ui/src/components/terminal/terminal-panel-upload-styles.ts
Normal file
142
ui/src/components/terminal/terminal-panel-upload-styles.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { css } from "lit";
|
||||
|
||||
export const terminalPanelUploadStyles = css`
|
||||
.tp-icon:disabled {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tp-file-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tp-drop-overlay {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
inset: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed var(--accent, #ff5c5c);
|
||||
background: color-mix(in srgb, var(--bg, #0e1015) 88%, var(--accent, #ff5c5c));
|
||||
color: var(--text, #d7dae0);
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tp-upload-card {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
width: min(300px, calc(100% - 20px));
|
||||
box-sizing: border-box;
|
||||
padding: 9px 10px 10px;
|
||||
border: 1px solid var(--border, #262b34);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--bg, #0e1015) 94%, var(--text, #d7dae0));
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
|
||||
color: var(--text, #d7dae0);
|
||||
font-size: 11px;
|
||||
}
|
||||
.tp-upload-card--failed {
|
||||
border-color: color-mix(in srgb, var(--danger, #ff6b6b) 55%, var(--border, #262b34));
|
||||
}
|
||||
.tp-upload-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.tp-upload-card__copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tp-upload-card__title {
|
||||
color: var(--text, #d7dae0);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tp-upload-card--failed .tp-upload-card__title,
|
||||
.tp-upload-card__error {
|
||||
color: var(--danger, #ff6b6b);
|
||||
}
|
||||
.tp-upload-card__file {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
color: var(--muted, #8a919e);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tp-upload-card__error {
|
||||
margin-top: 6px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.tp-upload-card__actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.tp-upload-card__action {
|
||||
margin: -3px 0;
|
||||
padding: 3px 5px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted, #8a919e);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tp-upload-card__action:hover {
|
||||
background: color-mix(in srgb, var(--text, #d7dae0) 10%, transparent);
|
||||
color: var(--text, #d7dae0);
|
||||
}
|
||||
.tp-upload-card__action:focus-visible {
|
||||
outline: 1px solid var(--accent, #ff5c5c);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.tp-upload-retry {
|
||||
color: var(--accent, #ff5c5c);
|
||||
}
|
||||
.tp-upload-progress {
|
||||
position: relative;
|
||||
height: 3px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--border, #262b34) 72%, transparent);
|
||||
}
|
||||
.tp-upload-progress__fill,
|
||||
.tp-upload-progress__activity {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--accent, #ff5c5c);
|
||||
}
|
||||
.tp-upload-progress__fill {
|
||||
transition: width 180ms ease-out;
|
||||
}
|
||||
.tp-upload-progress__activity {
|
||||
width: 26%;
|
||||
opacity: 0.7;
|
||||
animation: tp-upload-progress 1.15s ease-in-out infinite;
|
||||
}
|
||||
.tp-upload-card--failed .tp-upload-progress__fill {
|
||||
background: var(--danger, #ff6b6b);
|
||||
}
|
||||
@keyframes tp-upload-progress {
|
||||
from {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(385%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tp-upload-progress__activity {
|
||||
animation: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
143
ui/src/components/terminal/terminal-panel-upload.test.ts
Normal file
143
ui/src/components/terminal/terminal-panel-upload.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
import { i18n } from "../../i18n/index.ts";
|
||||
import { createStorageMock } from "../../test-helpers/storage.ts";
|
||||
import type { TerminalGatewayClient } from "./terminal-connection.ts";
|
||||
import { OpenClawTerminalPanel } from "./terminal-panel.ts";
|
||||
|
||||
type CreateOptions = {
|
||||
parent: HTMLElement;
|
||||
terminalOptions?: { fontFamily?: string };
|
||||
onData?: (bytes: Uint8Array) => void;
|
||||
onResize?: (size: { columns: number; rows: number }) => void;
|
||||
};
|
||||
|
||||
function createTerminalController() {
|
||||
return {
|
||||
readOnly: false,
|
||||
terminal: {
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
viewportY: 0,
|
||||
write: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
paste: vi.fn(),
|
||||
},
|
||||
write: vi.fn(),
|
||||
fit: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
setReadOnly: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
type TerminalFactory = typeof import("./terminal-runtime.ts").createIsolatedGhosttyTerminal;
|
||||
type CreateGhosttyTerminalMock = Mock<
|
||||
(options: CreateOptions) => Promise<ReturnType<typeof createTerminalController>>
|
||||
>;
|
||||
|
||||
const createGhosttyTerminalMock: CreateGhosttyTerminalMock = vi.fn();
|
||||
const TERMINAL_PANEL_ELEMENT_NAME = `test-openclaw-terminal-panel-upload-${crypto.randomUUID()}`;
|
||||
|
||||
class TestTerminalPanel extends OpenClawTerminalPanel {
|
||||
protected override createTerminal = createGhosttyTerminalMock as unknown as TerminalFactory;
|
||||
}
|
||||
|
||||
customElements.define(TERMINAL_PANEL_ELEMENT_NAME, TestTerminalPanel);
|
||||
|
||||
function terminalUploadFile(name: string, content: string): File {
|
||||
const file = new File([content], name);
|
||||
Object.defineProperty(file, "arrayBuffer", {
|
||||
value: async () => new TextEncoder().encode(content).buffer,
|
||||
});
|
||||
return file;
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((next, fail) => {
|
||||
resolve = next;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("OpenClawTerminalPanel upload lifecycle", () => {
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
vi.stubGlobal("sessionStorage", createStorageMock());
|
||||
await i18n.setLocale("en");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
document.body.replaceChildren();
|
||||
createGhosttyTerminalMock.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
await i18n.setLocale("en");
|
||||
});
|
||||
|
||||
it("cancels a pending upload when its terminal tab closes", async () => {
|
||||
const controller = createTerminalController();
|
||||
createGhosttyTerminalMock.mockResolvedValue(controller);
|
||||
const pendingUpload = deferred<{ path: string; size: number }>();
|
||||
let uploadSignal: AbortSignal | undefined;
|
||||
const client: TerminalGatewayClient = {
|
||||
request: async <T>(method: string, _params?: unknown, options?: { signal?: AbortSignal }) => {
|
||||
if (method === "terminal.open") {
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
agentId: "ops",
|
||||
shell: "/bin/zsh",
|
||||
cwd: "/work/ops",
|
||||
confined: false,
|
||||
} as T;
|
||||
}
|
||||
if (method === "terminal.upload") {
|
||||
uploadSignal = options?.signal;
|
||||
return (await pendingUpload.promise) as T;
|
||||
}
|
||||
return {} as T;
|
||||
},
|
||||
addEventListener: () => () => {},
|
||||
};
|
||||
const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel;
|
||||
panel.client = client;
|
||||
panel.available = true;
|
||||
document.body.append(panel);
|
||||
panel.toggle();
|
||||
await vi.waitFor(() => {
|
||||
expect(panel.renderRoot.querySelector<HTMLButtonElement>(".tp-upload")?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(drop, "dataTransfer", {
|
||||
value: {
|
||||
types: ["Files"],
|
||||
files: [terminalUploadFile("archive.zip", "zip")],
|
||||
dropEffect: "none",
|
||||
},
|
||||
});
|
||||
panel.renderRoot.querySelector(".tp-viewport")?.dispatchEvent(drop);
|
||||
await vi.waitFor(() => {
|
||||
expect(panel.renderRoot.querySelector(".tp-upload-card")?.textContent).toContain(
|
||||
"Uploading 1 of 1",
|
||||
);
|
||||
});
|
||||
|
||||
panel.renderRoot.querySelector<HTMLButtonElement>(".tp-tab__close")?.click();
|
||||
await vi.waitFor(() => {
|
||||
expect(uploadSignal?.aborted).toBe(true);
|
||||
expect(panel.renderRoot.querySelector(".tp-upload-card")).toBeNull();
|
||||
});
|
||||
|
||||
pendingUpload.reject(new Error("terminal closed"));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(controller.terminal.paste).not.toHaveBeenCalled();
|
||||
expect(panel.renderRoot.querySelector(".tp-upload-card")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GhosttyTerminalController } from "@openclaw/libterminal/browser";
|
||||
import { css, html, nothing, svg } from "lit";
|
||||
import { html, nothing, svg } from "lit";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { TerminalGatewayClient } from "./terminal-connection.ts";
|
||||
import {
|
||||
@@ -29,10 +29,41 @@ type TerminalPanelUploadHost = {
|
||||
requestUpdate: () => void;
|
||||
};
|
||||
|
||||
type TerminalUploadBatch = {
|
||||
tab: TerminalUploadTab;
|
||||
files: File[];
|
||||
paths: string[];
|
||||
nextIndex: number;
|
||||
state: "uploading" | "failed";
|
||||
error: string | null;
|
||||
retryable: boolean;
|
||||
abortController: AbortController;
|
||||
};
|
||||
|
||||
type TerminalUploadProgress = {
|
||||
completed: number;
|
||||
current: number;
|
||||
error: string | null;
|
||||
fileName: string;
|
||||
retryable: boolean;
|
||||
state: TerminalUploadBatch["state"];
|
||||
total: number;
|
||||
};
|
||||
|
||||
function isRetryableUploadError(error: unknown): boolean {
|
||||
if (typeof error === "object" && error !== null && "retryable" in error) {
|
||||
const gatewayError = error as { gatewayCode?: unknown; code?: unknown; retryable?: unknown };
|
||||
if (gatewayError.gatewayCode === "UNAVAILABLE" || gatewayError.code === "UNAVAILABLE") {
|
||||
return true;
|
||||
}
|
||||
return gatewayError.retryable === true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export class TerminalPanelUploadController {
|
||||
uploading = false;
|
||||
dragActive = false;
|
||||
private generation = 0;
|
||||
private batch: TerminalUploadBatch | null = null;
|
||||
private dragDepth = 0;
|
||||
|
||||
constructor(private readonly host: TerminalPanelUploadHost) {}
|
||||
@@ -41,6 +72,28 @@ export class TerminalPanelUploadController {
|
||||
return Boolean(this.host.activeTab());
|
||||
}
|
||||
|
||||
hasPendingBatch(): boolean {
|
||||
return this.batch !== null;
|
||||
}
|
||||
|
||||
get progress(): TerminalUploadProgress | null {
|
||||
const batch = this.batch;
|
||||
if (!batch) {
|
||||
return null;
|
||||
}
|
||||
const total = batch.files.length;
|
||||
const currentIndex = Math.min(batch.nextIndex, total - 1);
|
||||
return {
|
||||
completed: batch.nextIndex,
|
||||
current: currentIndex + 1,
|
||||
error: batch.error,
|
||||
fileName: batch.files[currentIndex]?.name ?? "",
|
||||
retryable: batch.retryable,
|
||||
state: batch.state,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
chooseFiles = (): void => {
|
||||
this.host.fileInput()?.click();
|
||||
};
|
||||
@@ -49,7 +102,7 @@ export class TerminalPanelUploadController {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
input.value = "";
|
||||
void this.uploadFiles(files);
|
||||
this.uploadFiles(files);
|
||||
};
|
||||
|
||||
private hasDraggedFiles(event: DragEvent): boolean {
|
||||
@@ -57,7 +110,7 @@ export class TerminalPanelUploadController {
|
||||
}
|
||||
|
||||
handleDragEnter = (event: DragEvent): void => {
|
||||
if (!this.hasDraggedFiles(event) || !this.hasActiveTab() || this.uploading) {
|
||||
if (!this.hasDraggedFiles(event) || !this.hasActiveTab() || this.hasPendingBatch()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
@@ -67,7 +120,7 @@ export class TerminalPanelUploadController {
|
||||
};
|
||||
|
||||
handleDragOver = (event: DragEvent): void => {
|
||||
if (!this.hasDraggedFiles(event) || !this.hasActiveTab() || this.uploading) {
|
||||
if (!this.hasDraggedFiles(event) || !this.hasActiveTab() || this.hasPendingBatch()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
@@ -95,51 +148,166 @@ export class TerminalPanelUploadController {
|
||||
this.dragDepth = 0;
|
||||
this.dragActive = false;
|
||||
this.host.requestUpdate();
|
||||
void this.uploadFiles(Array.from(event.dataTransfer?.files ?? []));
|
||||
};
|
||||
|
||||
private async uploadFiles(files: File[]): Promise<void> {
|
||||
const tab = this.host.activeTab();
|
||||
const client = this.host.client();
|
||||
if (files.length === 0 || !tab || !client || this.uploading) {
|
||||
if (this.hasPendingBatch()) {
|
||||
return;
|
||||
}
|
||||
this.uploadFiles(Array.from(event.dataTransfer?.files ?? []));
|
||||
};
|
||||
|
||||
private uploadFiles(files: File[]): void {
|
||||
const tab = this.host.activeTab();
|
||||
if (files.length === 0 || !tab || !this.host.client() || this.hasPendingBatch()) {
|
||||
return;
|
||||
}
|
||||
const generation = ++this.generation;
|
||||
this.uploading = true;
|
||||
this.host.setError(null);
|
||||
const batch: TerminalUploadBatch = {
|
||||
tab,
|
||||
files,
|
||||
paths: [],
|
||||
nextIndex: 0,
|
||||
state: "uploading",
|
||||
error: null,
|
||||
retryable: false,
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
this.batch = batch;
|
||||
this.host.requestUpdate();
|
||||
const paths: string[] = [];
|
||||
try {
|
||||
for (const file of files) {
|
||||
try {
|
||||
const contentBase64 = await encodeTerminalUpload(file);
|
||||
const result = await uploadTerminalFile(client, tab.gatewaySessionId, {
|
||||
name: file.name,
|
||||
contentBase64,
|
||||
});
|
||||
paths.push(quoteTerminalUploadPath(result.path, tab.shell));
|
||||
} catch (error) {
|
||||
this.host.setError(error instanceof Error ? error.message : String(error));
|
||||
break;
|
||||
void this.runBatch(batch);
|
||||
}
|
||||
|
||||
private isActive(batch: TerminalUploadBatch): boolean {
|
||||
return this.batch === batch && !batch.abortController.signal.aborted;
|
||||
}
|
||||
|
||||
private ensureCurrent(batch: TerminalUploadBatch): boolean {
|
||||
if (!this.isActive(batch)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.host.isCurrent(batch.tab)) {
|
||||
this.cancelBatch(batch);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private failBatch(batch: TerminalUploadBatch, error: unknown, retryable: boolean): void {
|
||||
if (!this.ensureCurrent(batch)) {
|
||||
return;
|
||||
}
|
||||
batch.state = "failed";
|
||||
batch.error = error instanceof Error ? error.message : String(error);
|
||||
batch.retryable = retryable;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
private async runBatch(batch: TerminalUploadBatch): Promise<void> {
|
||||
const client = this.host.client();
|
||||
if (!client || !this.ensureCurrent(batch)) {
|
||||
this.cancelBatch(batch);
|
||||
return;
|
||||
}
|
||||
while (batch.nextIndex < batch.files.length) {
|
||||
const file = batch.files[batch.nextIndex];
|
||||
if (!file || !this.ensureCurrent(batch)) {
|
||||
return;
|
||||
}
|
||||
this.host.requestUpdate();
|
||||
|
||||
let contentBase64: string;
|
||||
try {
|
||||
contentBase64 = await encodeTerminalUpload(file);
|
||||
} catch (error) {
|
||||
this.failBatch(batch, error, false);
|
||||
return;
|
||||
}
|
||||
if (!this.ensureCurrent(batch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let uploadedPath: string;
|
||||
try {
|
||||
const result = await uploadTerminalFile(
|
||||
client,
|
||||
batch.tab.gatewaySessionId,
|
||||
{ name: file.name, contentBase64 },
|
||||
batch.abortController.signal,
|
||||
);
|
||||
if (!this.ensureCurrent(batch)) {
|
||||
return;
|
||||
}
|
||||
uploadedPath = result.path;
|
||||
} catch (error) {
|
||||
this.failBatch(batch, error, isRetryableUploadError(error));
|
||||
return;
|
||||
}
|
||||
if (paths.length > 0 && generation === this.generation && this.host.isCurrent(tab)) {
|
||||
// Ghostty preserves bracketed-paste mode. This produces editable input,
|
||||
// never Enter, so adding a file cannot execute a shell command.
|
||||
tab.controller.terminal.paste(paths.join(" "));
|
||||
tab.controller.terminal.focus();
|
||||
}
|
||||
} finally {
|
||||
if (generation === this.generation) {
|
||||
this.uploading = false;
|
||||
this.host.requestUpdate();
|
||||
try {
|
||||
uploadedPath = quoteTerminalUploadPath(uploadedPath, batch.tab.shell);
|
||||
} catch (error) {
|
||||
this.failBatch(batch, error, false);
|
||||
return;
|
||||
}
|
||||
|
||||
batch.paths.push(uploadedPath);
|
||||
batch.nextIndex += 1;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
if (!this.ensureCurrent(batch)) {
|
||||
return;
|
||||
}
|
||||
// Ghostty preserves bracketed-paste mode. This produces editable input,
|
||||
// never Enter, so adding a file cannot execute a shell command.
|
||||
batch.tab.controller.terminal.paste(batch.paths.join(" "));
|
||||
batch.tab.controller.terminal.focus();
|
||||
this.batch = null;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
retry = (): void => {
|
||||
const batch = this.batch;
|
||||
if (!batch || batch.state !== "failed" || !batch.retryable) {
|
||||
return;
|
||||
}
|
||||
if (!this.host.isCurrent(batch.tab) || !this.host.client()) {
|
||||
this.cancelBatch(batch);
|
||||
return;
|
||||
}
|
||||
batch.state = "uploading";
|
||||
batch.error = null;
|
||||
batch.retryable = false;
|
||||
batch.abortController = new AbortController();
|
||||
this.host.requestUpdate();
|
||||
void this.runBatch(batch);
|
||||
};
|
||||
|
||||
cancel = (): void => {
|
||||
const batch = this.batch;
|
||||
if (batch) {
|
||||
this.cancelBatch(batch);
|
||||
}
|
||||
};
|
||||
|
||||
cancelForTab(tab: TerminalUploadTab): void {
|
||||
const batch = this.batch;
|
||||
if (batch?.tab === tab) {
|
||||
this.cancelBatch(batch);
|
||||
}
|
||||
}
|
||||
|
||||
private cancelBatch(batch: TerminalUploadBatch): void {
|
||||
if (this.batch !== batch) {
|
||||
return;
|
||||
}
|
||||
batch.abortController.abort();
|
||||
this.batch = null;
|
||||
this.dragActive = false;
|
||||
this.dragDepth = 0;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.generation += 1;
|
||||
this.uploading = false;
|
||||
this.batch?.abortController.abort();
|
||||
this.batch = null;
|
||||
this.dragActive = false;
|
||||
this.dragDepth = 0;
|
||||
}
|
||||
@@ -167,7 +335,7 @@ export function renderTerminalPanelActions(params: {
|
||||
type="button"
|
||||
title=${t("terminal.addFiles")}
|
||||
aria-label=${t("terminal.addFiles")}
|
||||
?disabled=${params.upload.uploading || !params.upload.hasActiveTab()}
|
||||
?disabled=${params.upload.hasPendingBatch() || !params.upload.hasActiveTab()}
|
||||
@click=${params.upload.chooseFiles}
|
||||
>
|
||||
${UPLOAD_GLYPH}
|
||||
@@ -205,50 +373,71 @@ export function renderTerminalPanelActions(params: {
|
||||
}
|
||||
|
||||
export function renderTerminalUploadLayer(upload: TerminalPanelUploadController) {
|
||||
const progress = upload.progress;
|
||||
return html`${upload.dragActive
|
||||
? html`<div class="tp-drop-overlay">${t("terminal.dropFiles")}</div>`
|
||||
: nothing}
|
||||
${upload.uploading
|
||||
? html`<div class="tp-upload-status" role="status">${t("terminal.uploading")}</div>`
|
||||
${progress
|
||||
? html`<div
|
||||
class="tp-upload-card ${progress.state === "failed" ? "tp-upload-card--failed" : ""}"
|
||||
role=${progress.state === "failed" ? "alert" : "status"}
|
||||
aria-live=${progress.state === "failed" ? "assertive" : "polite"}
|
||||
>
|
||||
<div class="tp-upload-card__header">
|
||||
<div class="tp-upload-card__copy">
|
||||
<div class="tp-upload-card__title">
|
||||
${progress.state === "failed"
|
||||
? t("terminal.uploadFailed")
|
||||
: t("terminal.uploadProgress", {
|
||||
current: String(progress.current),
|
||||
total: String(progress.total),
|
||||
})}
|
||||
</div>
|
||||
<div class="tp-upload-card__file">${progress.fileName}</div>
|
||||
</div>
|
||||
<div class="tp-upload-card__actions">
|
||||
${progress.state === "failed" && progress.retryable
|
||||
? html`<button
|
||||
class="tp-upload-card__action tp-upload-retry"
|
||||
type="button"
|
||||
@click=${upload.retry}
|
||||
>
|
||||
${t("terminal.retryUpload")}
|
||||
</button>`
|
||||
: nothing}
|
||||
<button
|
||||
class="tp-upload-card__action tp-upload-cancel"
|
||||
type="button"
|
||||
@click=${upload.cancel}
|
||||
>
|
||||
${t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="tp-upload-progress"
|
||||
role="progressbar"
|
||||
aria-label=${progress.state === "failed"
|
||||
? t("terminal.uploadFailed")
|
||||
: t("terminal.uploadProgress", {
|
||||
current: String(progress.current),
|
||||
total: String(progress.total),
|
||||
})}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax=${String(progress.total)}
|
||||
aria-valuenow=${String(progress.completed)}
|
||||
>
|
||||
<span
|
||||
class="tp-upload-progress__fill"
|
||||
style=${`width:${(progress.completed / progress.total) * 100}%`}
|
||||
></span>
|
||||
${progress.state === "uploading"
|
||||
? html`<span class="tp-upload-progress__activity"></span>`
|
||||
: nothing}
|
||||
</div>
|
||||
${progress.error
|
||||
? html`<div class="tp-upload-card__error">${progress.error}</div>`
|
||||
: nothing}
|
||||
</div>`
|
||||
: nothing}`;
|
||||
}
|
||||
|
||||
export const terminalPanelUploadStyles = css`
|
||||
.tp-icon:disabled {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tp-file-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tp-drop-overlay {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
inset: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed var(--accent, #ff5c5c);
|
||||
background: color-mix(in srgb, var(--bg, #0e1015) 88%, var(--accent, #ff5c5c));
|
||||
color: var(--text, #d7dae0);
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tp-upload-status {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
right: 10px;
|
||||
bottom: 8px;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid var(--border, #262b34);
|
||||
border-radius: 4px;
|
||||
background: var(--bg, #0e1015);
|
||||
color: var(--muted, #8a919e);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -50,6 +50,14 @@ function terminalOpenResult(sessionId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function terminalUploadFile(name: string, content: string): File {
|
||||
const file = new File([content], name);
|
||||
Object.defineProperty(file, "arrayBuffer", {
|
||||
value: async () => new TextEncoder().encode(content).buffer,
|
||||
});
|
||||
return file;
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -890,6 +898,147 @@ describe("OpenClawTerminalPanel", () => {
|
||||
expect(controller.terminal.paste).not.toHaveBeenCalledWith(expect.stringContaining("\n"));
|
||||
});
|
||||
|
||||
it("shows file progress and retries only the failed remainder", async () => {
|
||||
const controller = createTerminalController();
|
||||
createGhosttyTerminalMock.mockResolvedValue(controller);
|
||||
const requests: Array<{ method: string; params: unknown; signal?: AbortSignal }> = [];
|
||||
const failedUpload = deferred<{ path: string; size: number }>();
|
||||
let notesAttempts = 0;
|
||||
const client: TerminalGatewayClient = {
|
||||
request: async <T>(method: string, params?: unknown, options?: { signal?: AbortSignal }) => {
|
||||
requests.push({ method, params, signal: options?.signal });
|
||||
if (method === "terminal.open") {
|
||||
return terminalOpenResult("session-1") as T;
|
||||
}
|
||||
if (method === "terminal.upload") {
|
||||
const name = (params as { name: string }).name;
|
||||
if (name === "scan final.pdf") {
|
||||
return { path: "/tmp/openclaw upload/scan final.pdf", size: 3 } as T;
|
||||
}
|
||||
notesAttempts += 1;
|
||||
if (notesAttempts === 1) {
|
||||
return (await failedUpload.promise) as T;
|
||||
}
|
||||
return { path: "/tmp/openclaw upload/notes.txt", size: 4 } as T;
|
||||
}
|
||||
return {} as T;
|
||||
},
|
||||
addEventListener: () => () => {},
|
||||
};
|
||||
const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel;
|
||||
panel.client = client;
|
||||
panel.available = true;
|
||||
document.body.append(panel);
|
||||
panel.toggle();
|
||||
await vi.waitFor(() => {
|
||||
expect(panel.renderRoot.querySelector<HTMLButtonElement>(".tp-upload")?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(drop, "dataTransfer", {
|
||||
value: {
|
||||
types: ["Files"],
|
||||
files: [
|
||||
terminalUploadFile("scan final.pdf", "pdf"),
|
||||
terminalUploadFile("notes.txt", "note"),
|
||||
],
|
||||
dropEffect: "none",
|
||||
},
|
||||
});
|
||||
panel.renderRoot.querySelector(".tp-viewport")?.dispatchEvent(drop);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const progress = panel.renderRoot.querySelector(".tp-upload-progress");
|
||||
expect(progress?.getAttribute("aria-valuenow")).toBe("1");
|
||||
expect(progress?.getAttribute("aria-valuemax")).toBe("2");
|
||||
expect(panel.renderRoot.querySelector(".tp-upload-card")?.textContent).toContain(
|
||||
"Uploading 2 of 2",
|
||||
);
|
||||
expect(panel.renderRoot.querySelector(".tp-upload-card")?.textContent).toContain("notes.txt");
|
||||
});
|
||||
expect(controller.terminal.paste).not.toHaveBeenCalled();
|
||||
|
||||
failedUpload.reject(
|
||||
Object.assign(new Error("paired node went offline"), {
|
||||
gatewayCode: "UNAVAILABLE",
|
||||
retryable: false,
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const failed = panel.renderRoot.querySelector(".tp-upload-card--failed");
|
||||
expect(failed?.textContent).toContain("Upload failed");
|
||||
expect(failed?.textContent).toContain("paired node went offline");
|
||||
expect(panel.renderRoot.querySelector<HTMLButtonElement>(".tp-upload-retry")).not.toBeNull();
|
||||
});
|
||||
panel.renderRoot.querySelector<HTMLButtonElement>(".tp-upload-retry")?.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(controller.terminal.paste).toHaveBeenCalledWith(
|
||||
"'/tmp/openclaw upload/scan final.pdf' '/tmp/openclaw upload/notes.txt'",
|
||||
);
|
||||
expect(panel.renderRoot.querySelector(".tp-upload-card")).toBeNull();
|
||||
});
|
||||
expect(
|
||||
requests
|
||||
.filter(({ method }) => method === "terminal.upload")
|
||||
.map(({ params }) => (params as { name: string }).name),
|
||||
).toEqual(["scan final.pdf", "notes.txt", "notes.txt"]);
|
||||
});
|
||||
|
||||
it("cancels an active batch without pasting staged paths", async () => {
|
||||
const controller = createTerminalController();
|
||||
createGhosttyTerminalMock.mockResolvedValue(controller);
|
||||
const pendingUpload = deferred<{ path: string; size: number }>();
|
||||
let uploadSignal: AbortSignal | undefined;
|
||||
const client: TerminalGatewayClient = {
|
||||
request: async <T>(method: string, _params?: unknown, options?: { signal?: AbortSignal }) => {
|
||||
if (method === "terminal.open") {
|
||||
return terminalOpenResult("session-1") as T;
|
||||
}
|
||||
if (method === "terminal.upload") {
|
||||
uploadSignal = options?.signal;
|
||||
return (await pendingUpload.promise) as T;
|
||||
}
|
||||
return {} as T;
|
||||
},
|
||||
addEventListener: () => () => {},
|
||||
};
|
||||
const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel;
|
||||
panel.client = client;
|
||||
panel.available = true;
|
||||
document.body.append(panel);
|
||||
panel.toggle();
|
||||
await vi.waitFor(() => {
|
||||
expect(panel.renderRoot.querySelector<HTMLButtonElement>(".tp-upload")?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(drop, "dataTransfer", {
|
||||
value: {
|
||||
types: ["Files"],
|
||||
files: [terminalUploadFile("archive.zip", "zip")],
|
||||
dropEffect: "none",
|
||||
},
|
||||
});
|
||||
panel.renderRoot.querySelector(".tp-viewport")?.dispatchEvent(drop);
|
||||
await vi.waitFor(() => {
|
||||
expect(panel.renderRoot.querySelector(".tp-upload-card")?.textContent).toContain(
|
||||
"Uploading 1 of 1",
|
||||
);
|
||||
});
|
||||
|
||||
panel.renderRoot.querySelector<HTMLButtonElement>(".tp-upload-cancel")?.click();
|
||||
await panel.updateComplete;
|
||||
expect(uploadSignal?.aborted).toBe(true);
|
||||
expect(panel.renderRoot.querySelector(".tp-upload-card")).toBeNull();
|
||||
expect(panel.renderRoot.querySelector<HTMLButtonElement>(".tp-upload")?.disabled).toBe(false);
|
||||
|
||||
pendingUpload.resolve({ path: "/tmp/openclaw upload/archive.zip", size: 3 });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(controller.terminal.paste).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retranslates cached exit state when the locale changes", async () => {
|
||||
createGhosttyTerminalMock.mockResolvedValue(createTerminalController());
|
||||
let listener: ((event: { event: string; payload: unknown }) => void) | undefined;
|
||||
|
||||
@@ -22,11 +22,11 @@ import {
|
||||
} from "./terminal-connection.ts";
|
||||
import { terminalPanelStyles } from "./terminal-panel-styles.ts";
|
||||
import { renderTerminalPanelTabs, type TerminalPanelTab } from "./terminal-panel-tabs.ts";
|
||||
import { terminalPanelUploadStyles } from "./terminal-panel-upload-styles.ts";
|
||||
import {
|
||||
renderTerminalPanelActions,
|
||||
renderTerminalUploadLayer,
|
||||
TerminalPanelUploadController,
|
||||
terminalPanelUploadStyles,
|
||||
} from "./terminal-panel-upload.ts";
|
||||
import { createIsolatedGhosttyTerminal } from "./terminal-runtime.ts";
|
||||
import { renderTerminalSessionPicker } from "./terminal-session-picker.ts";
|
||||
@@ -696,6 +696,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
this.upload.cancelForTab(tab);
|
||||
if (tab.gatewaySessionId && tab.status === "live") {
|
||||
void this.connection?.close(tab.gatewaySessionId);
|
||||
} else if (!tab.gatewaySessionId && tab.status === "live") {
|
||||
|
||||
@@ -14,6 +14,9 @@ const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM
|
||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
const expectUploadSurface = process.env.OPENCLAW_TERMINAL_UPLOAD_EXPECT_PRESENT !== "0";
|
||||
const screenshotPath = process.env.OPENCLAW_TERMINAL_UPLOAD_SCREENSHOT?.trim();
|
||||
const progressScreenshotPath = process.env.OPENCLAW_TERMINAL_UPLOAD_PROGRESS_SCREENSHOT?.trim();
|
||||
const errorScreenshotPath = process.env.OPENCLAW_TERMINAL_UPLOAD_ERROR_SCREENSHOT?.trim();
|
||||
const videoDir = process.env.OPENCLAW_TERMINAL_UPLOAD_VIDEO_DIR?.trim();
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
@@ -38,6 +41,7 @@ describeControlUiE2e("Control UI terminal file upload", () => {
|
||||
const context = await browser.newContext({
|
||||
serviceWorkers: "block",
|
||||
viewport: { width: 1280, height: 800 },
|
||||
...(videoDir ? { recordVideo: { dir: videoDir, size: { width: 1280, height: 800 } } } : {}),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(() => {
|
||||
@@ -51,6 +55,8 @@ describeControlUiE2e("Control UI terminal file upload", () => {
|
||||
};
|
||||
});
|
||||
const stagedPath = "/tmp/openclaw-terminal-upload/sample file.pdf";
|
||||
const stagedNotesPath = "/tmp/openclaw-terminal-upload/notes.txt";
|
||||
const stagedDropPath = "/tmp/openclaw-terminal-upload/dropped.png";
|
||||
const gateway = await installMockGateway(page, {
|
||||
deferredMethods: ["connect"],
|
||||
featureMethods: ["terminal.open", "terminal.upload"],
|
||||
@@ -89,17 +95,54 @@ describeControlUiE2e("Control UI terminal file upload", () => {
|
||||
await page.screenshot({ path: screenshotPath });
|
||||
}
|
||||
|
||||
await gateway.deferNext("terminal.upload");
|
||||
await page.locator("input.tp-file-input").setInputFiles([
|
||||
{ name: "sample file.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF") },
|
||||
{ name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("note") },
|
||||
]);
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("terminal.upload")).length, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(1);
|
||||
await page.getByText("Uploading 1 of 2").waitFor();
|
||||
const progress = page.locator(".tp-upload-progress");
|
||||
await expect.poll(async () => await progress.getAttribute("aria-valuenow")).toBe("0");
|
||||
await expect.poll(async () => await progress.getAttribute("aria-valuemax")).toBe("2");
|
||||
if (progressScreenshotPath) {
|
||||
await page.screenshot({ path: progressScreenshotPath });
|
||||
}
|
||||
|
||||
await gateway.deferNext("terminal.upload");
|
||||
await gateway.resolveDeferred("terminal.upload", { path: stagedPath, size: 4 });
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("terminal.upload")).length, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(2);
|
||||
await page.getByText("Uploading 2 of 2").waitFor();
|
||||
await expect.poll(async () => await progress.getAttribute("aria-valuenow")).toBe("1");
|
||||
await gateway.rejectDeferred("terminal.upload", {
|
||||
code: "UNAVAILABLE",
|
||||
message: "paired node went offline",
|
||||
});
|
||||
await page.getByText("Upload failed").waitFor();
|
||||
await page.getByText("paired node went offline").waitFor();
|
||||
expect(await page.getByRole("button", { name: "Retry" }).isVisible()).toBe(true);
|
||||
expect((await gateway.getRequests("terminal.input")).length).toBe(0);
|
||||
if (errorScreenshotPath) {
|
||||
await page.screenshot({ path: errorScreenshotPath });
|
||||
}
|
||||
|
||||
await gateway.setMethodResponse("terminal.upload", { path: stagedNotesPath, size: 4 });
|
||||
await page.getByRole("button", { name: "Retry" }).click();
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("terminal.upload")).length, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(3);
|
||||
const pickedUploads = await gateway.getRequests("terminal.upload");
|
||||
expect(pickedUploads.map((request) => request.params)).toEqual([
|
||||
expect(pickedUploads.slice(0, 3).map((request) => request.params)).toEqual([
|
||||
{
|
||||
sessionId: "terminal-upload-e2e",
|
||||
name: "sample file.pdf",
|
||||
@@ -110,6 +153,11 @@ describeControlUiE2e("Control UI terminal file upload", () => {
|
||||
name: "notes.txt",
|
||||
contentBase64: "bm90ZQ==",
|
||||
},
|
||||
{
|
||||
sessionId: "terminal-upload-e2e",
|
||||
name: "notes.txt",
|
||||
contentBase64: "bm90ZQ==",
|
||||
},
|
||||
]);
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("terminal.input")).length, {
|
||||
@@ -120,8 +168,10 @@ describeControlUiE2e("Control UI terminal file upload", () => {
|
||||
data?: string;
|
||||
};
|
||||
expect(pickedInput.data).toContain("'/tmp/openclaw-terminal-upload/sample file.pdf'");
|
||||
expect(pickedInput.data).toContain("/tmp/openclaw-terminal-upload/notes.txt");
|
||||
expect(pickedInput.data).not.toMatch(/[\r\n]/);
|
||||
|
||||
await gateway.setMethodResponse("terminal.upload", { path: stagedDropPath, size: 3 });
|
||||
await page.locator("wa-tab-panel.tp-viewport").evaluate((target) => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([new Uint8Array([1, 2, 3])], "dropped.png"));
|
||||
@@ -136,7 +186,7 @@ describeControlUiE2e("Control UI terminal file upload", () => {
|
||||
.poll(async () => (await gateway.getRequests("terminal.upload")).length, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(3);
|
||||
.toBe(4);
|
||||
const droppedUpload = (await gateway.getRequests("terminal.upload")).at(-1);
|
||||
expect(droppedUpload?.params).toEqual({
|
||||
sessionId: "terminal-upload-e2e",
|
||||
@@ -151,8 +201,29 @@ describeControlUiE2e("Control UI terminal file upload", () => {
|
||||
const droppedInput = (await gateway.getRequests("terminal.input")).at(-1)?.params as {
|
||||
data?: string;
|
||||
};
|
||||
expect(droppedInput.data).toContain("'/tmp/openclaw-terminal-upload/sample file.pdf'");
|
||||
expect(droppedInput.data).toContain("/tmp/openclaw-terminal-upload/dropped.png");
|
||||
expect(droppedInput.data).not.toMatch(/[\r\n]/);
|
||||
|
||||
await gateway.deferNext("terminal.upload");
|
||||
await page.locator("input.tp-file-input").setInputFiles({
|
||||
name: "cancelled.zip",
|
||||
mimeType: "application/zip",
|
||||
buffer: Buffer.from("zip"),
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("terminal.upload")).length, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(5);
|
||||
await page.getByText("Uploading 1 of 1").waitFor();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect.poll(async () => await page.locator(".tp-upload-card").count()).toBe(0);
|
||||
await gateway.resolveDeferred("terminal.upload", {
|
||||
path: "/tmp/openclaw-terminal-upload/cancelled.zip",
|
||||
size: 3,
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
expect((await gateway.getRequests("terminal.input")).length).toBe(2);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
|
||||
71
ui/src/i18n/.i18n/catalog-fallbacks.json
generated
71
ui/src/i18n/.i18n/catalog-fallbacks.json
generated
@@ -1,5 +1,72 @@
|
||||
{
|
||||
"fallbacks": {},
|
||||
"sourceHash": "f4984e3b88ac641e8380cf53c07074a0fb2449bacd5b57d162fd47459756c06a",
|
||||
"fallbacks": {
|
||||
"terminal.retryUpload": [
|
||||
"ar",
|
||||
"de",
|
||||
"es",
|
||||
"fa",
|
||||
"fr",
|
||||
"hi",
|
||||
"id",
|
||||
"it",
|
||||
"ja-JP",
|
||||
"ko",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt-BR",
|
||||
"ru",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh-CN",
|
||||
"zh-TW"
|
||||
],
|
||||
"terminal.uploadFailed": [
|
||||
"ar",
|
||||
"de",
|
||||
"es",
|
||||
"fa",
|
||||
"fr",
|
||||
"hi",
|
||||
"id",
|
||||
"it",
|
||||
"ja-JP",
|
||||
"ko",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt-BR",
|
||||
"ru",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh-CN",
|
||||
"zh-TW"
|
||||
],
|
||||
"terminal.uploadProgress": [
|
||||
"ar",
|
||||
"de",
|
||||
"es",
|
||||
"fa",
|
||||
"fr",
|
||||
"hi",
|
||||
"id",
|
||||
"it",
|
||||
"ja-JP",
|
||||
"ko",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt-BR",
|
||||
"ru",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh-CN",
|
||||
"zh-TW"
|
||||
]
|
||||
},
|
||||
"sourceHash": "006327019ed345834f5f156faf570ac9141eb6d4f49b06bb9fc68545a48ecd25",
|
||||
"version": 1
|
||||
}
|
||||
|
||||
@@ -1562,6 +1562,9 @@ export const en: TranslationMap = {
|
||||
addFiles: "Add files to terminal",
|
||||
dropFiles: "Drop files to add them",
|
||||
uploading: "Uploading files…",
|
||||
uploadProgress: "Uploading {current} of {total}",
|
||||
uploadFailed: "Upload failed",
|
||||
retryUpload: "Retry",
|
||||
closeSession: "Close terminal session",
|
||||
sessions: "Terminal sessions",
|
||||
refreshSessions: "Refresh",
|
||||
|
||||
Reference in New Issue
Block a user