Surface computer disconnect failures after closing the panel

This commit is contained in:
Dotta committed 2026-10-11 04:53:29 -05:00
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>,
);
});