From 2605a03f00bc3d7decc8b8d5549ee06f8d77ae85 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 11 Oct 2026 04:53:11 -0500 Subject: [PATCH] Surface computer disconnect failures after closing the panel --- .../TaskComputerPanel.test.tsx | 37 +++++++++++++++++-- .../task-side-panel/TaskComputerPanel.tsx | 15 +++++++- .../task-side-panel/TaskSidePanel.test.tsx | 3 +- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/ui/src/components/task-side-panel/TaskComputerPanel.test.tsx b/ui/src/components/task-side-panel/TaskComputerPanel.test.tsx index ee143e15c1..56e03aa528 100644 --- a/ui/src/components/task-side-panel/TaskComputerPanel.test.tsx +++ b/ui/src/components/task-side-panel/TaskComputerPanel.test.tsx @@ -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() { let resolve!: (value: T) => void; - const promise = new Promise(done => { resolve = done; }); - return { promise, resolve }; + let reject!: (error: Error) => void; + const promise = new Promise((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 {toasts.map(toast => `${toast.title}: ${toast.body}`).join("\n")}; +} async function render(props: { issueId?: string; environmentId?: string; active?: boolean } = {}) { - await act(async () => root.render()); + await act(async () => root.render()); } 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(); + vi.mocked(computersApi.disconnect).mockReturnValue(pending.promise); + await render(); await connect(); + await act(async () => root.render()); + 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>(); vi.mocked(computersApi.presence).mockReturnValueOnce(pending.promise); diff --git a/ui/src/components/task-side-panel/TaskComputerPanel.tsx b/ui/src/components/task-side-panel/TaskComputerPanel.tsx index da3cb214dd..d92a70dac5 100644 --- a/ui/src/components/task-side-panel/TaskComputerPanel.tsx +++ b/ui/src/components/task-side-panel/TaskComputerPanel.tsx @@ -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(null); const [connecting, setConnecting] = useState(false); const [error, setError] = useState(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, + }); }); } diff --git a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx index 9ead4a135c..dde6707002 100644 --- a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx +++ b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx @@ -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( - {node} + {node} , ); });