mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
Surface computer disconnect failures after closing the panel
This commit is contained in:
1 parent
683696352d
commit
2605a03f00
3 files changed
+50
-5
No files matched your search
@@ -3,6 +3,7 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { computersApi } from "@/api/computers";
|
||||
import { ToastProvider, useToastState } from "@/context/ToastContext";
|
||||
import { TaskComputerPanel } from "./TaskComputerPanel";
|
||||
|
||||
vi.mock("@/api/computers", () => ({ computersApi: {
|
||||
@@ -14,12 +15,17 @@ const now = new Date("2026-10-10T12:00:00Z");
|
||||
const viewer = () => ({ viewerUrl: "https://viewer.example/#private-token", expiresAt: new Date(now.getTime() + 120_000).toISOString(), owner });
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
let root: Root, host: HTMLDivElement, hidden = false, paneVisible = true, unmounted = false;
|
||||
function ToastProbe() {
|
||||
const toasts = useToastState();
|
||||
return <output>{toasts.map(toast => `${toast.title}: ${toast.body}`).join("\n")}</output>;
|
||||
}
|
||||
async function render(props: { issueId?: string; environmentId?: string; active?: boolean } = {}) {
|
||||
await act(async () => root.render(<TaskComputerPanel issueId={props.issueId ?? "task-a"} environmentId={props.environmentId ?? "environment-a"} active={props.active ?? true} />));
|
||||
await act(async () => root.render(<ToastProvider><TaskComputerPanel issueId={props.issueId ?? "task-a"} environmentId={props.environmentId ?? "environment-a"} active={props.active ?? true} /><ToastProbe /></ToastProvider>));
|
||||
}
|
||||
async function connect() {
|
||||
const button = Array.from(host.querySelectorAll("button")).find(item => item.textContent === "Connect");
|
||||
@@ -137,6 +143,31 @@ describe("Computer connection lifetime", () => {
|
||||
expect(computersApi.disconnect).toHaveBeenCalledWith("task-a", "environment-a", owner);
|
||||
});
|
||||
|
||||
it("shows a disconnect failure inline while the computer panel remains open", async () => {
|
||||
vi.mocked(computersApi.disconnect).mockRejectedValue(new Error("private-token"));
|
||||
await render(); await connect();
|
||||
await render({ active: false });
|
||||
await tick(120_000);
|
||||
expect(host.querySelector('[role="alert"]')?.textContent).toContain("Computer disconnect could not be confirmed");
|
||||
expect(host.querySelector("output")?.textContent).toBe("");
|
||||
expect(host.textContent).not.toContain("private-token");
|
||||
});
|
||||
|
||||
it("keeps a safe disconnect failure notice visible after the computer panel closes", async () => {
|
||||
const pending = deferred<void>();
|
||||
vi.mocked(computersApi.disconnect).mockReturnValue(pending.promise);
|
||||
await render(); await connect();
|
||||
await act(async () => root.render(<ToastProvider><ToastProbe /></ToastProvider>));
|
||||
expect(host.querySelector("iframe")).toBeNull();
|
||||
expect(computersApi.disconnect).toHaveBeenCalledWith("task-a", "environment-a", owner);
|
||||
await act(async () => pending.reject(new Error("private-token https://viewer.example/secret")));
|
||||
expect(host.textContent).toContain("Computer disconnect could not be confirmed");
|
||||
expect(host.textContent).toContain("may stay awake until this viewer connection expires");
|
||||
expect(host.textContent).not.toContain("private-token");
|
||||
expect(host.textContent).not.toContain("https://viewer.example");
|
||||
expect(computersApi.connect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retires a connected owner on unmount even when its presence response is pending", async () => {
|
||||
const pending = deferred<ReturnType<typeof viewer>>();
|
||||
vi.mocked(computersApi.presence).mockReturnValueOnce(pending.promise);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { ComputerViewer } from "@paperclipai/shared";
|
||||
import { LoaderCircle, Monitor } from "lucide-react";
|
||||
import { computersApi } from "@/api/computers";
|
||||
import { useToastActions } from "@/context/ToastContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
@@ -14,6 +15,7 @@ export function TaskComputerPanel(props: ComputerPanelProps) {
|
||||
}
|
||||
|
||||
function ComputerConnection({ issueId, environmentId, active = true }: ComputerPanelProps) {
|
||||
const { pushToast } = useToastActions();
|
||||
const [viewer, setViewer] = useState<ComputerViewer | null>(null);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -45,7 +47,18 @@ function ComputerConnection({ issueId, environmentId, active = true }: ComputerP
|
||||
|
||||
function release(view: ComputerViewer) {
|
||||
void computersApi.disconnect(issueId, environmentId, view.owner).catch(() => {
|
||||
// The server's bounded presence deadline also releases lost viewers.
|
||||
const title = "Computer disconnect could not be confirmed";
|
||||
const body = "The computer may stay awake until this viewer connection expires. You can leave the panel closed.";
|
||||
if (mounted.current) {
|
||||
setError(`${title}. ${body}`);
|
||||
return;
|
||||
}
|
||||
pushToast({
|
||||
dedupeKey: `computer-disconnect:${view.owner.ownerId}:${view.owner.generation}`,
|
||||
tone: "warn",
|
||||
title,
|
||||
body,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, IssueDocument } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ToastProvider } from "@/context/ToastContext";
|
||||
import {
|
||||
taskPanelAgentTasksTab,
|
||||
taskPanelArtifactsTab,
|
||||
@@ -170,7 +171,7 @@ describe("TaskSidePanel", () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>{node}</TooltipProvider>
|
||||
<ToastProvider><TooltipProvider>{node}</TooltipProvider></ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user