diff --git a/ui/src/components/task-side-panel/TaskComputerPanel.test.tsx b/ui/src/components/task-side-panel/TaskComputerPanel.test.tsx
index c97a3be6890..ee143e15c14 100644
--- a/ui/src/components/task-side-panel/TaskComputerPanel.test.tsx
+++ b/ui/src/components/task-side-panel/TaskComputerPanel.test.tsx
@@ -41,6 +41,35 @@ beforeEach(() => {
afterEach(async () => { if (!unmounted) await unmount(); host.remove(); delete (Element.prototype as { checkVisibility?: unknown }).checkVisibility; vi.restoreAllMocks(); vi.useRealTimers(); });
describe("Computer connection lifetime", () => {
+ it("offers an explicit preview link when the browser blocks the new window", async () => {
+ vi.spyOn(window, "open").mockReturnValue(null);
+ vi.mocked(computersApi.preview).mockResolvedValue({ url: "https://preview.example/app" });
+ await render();
+ await act(async () => host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
+ expect(computersApi.preview).toHaveBeenCalledWith("task-a", "environment-a", 5173);
+ const link = host.querySelector("a")!;
+ expect(link.href).toBe("https://preview.example/app");
+ expect(link.textContent).toBe("Open port 5173 in a new tab");
+ expect(link.rel).toBe("noopener noreferrer");
+ await render({ environmentId: "environment-b" });
+ expect(host.querySelector("a")).toBeNull();
+ });
+
+ it("discards a preview response after the task changes", async () => {
+ const close = vi.fn();
+ const replace = vi.fn();
+ vi.spyOn(window, "open").mockReturnValue({ close, location: { replace } } as unknown as Window);
+ const pending = deferred<{ url: string }>();
+ vi.mocked(computersApi.preview).mockReturnValue(pending.promise);
+ await render();
+ await act(async () => host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
+ await render({ issueId: "task-b" });
+ await act(async () => pending.resolve({ url: "https://preview.example/app" }));
+ expect(close).toHaveBeenCalledOnce();
+ expect(replace).not.toHaveBeenCalled();
+ expect(host.querySelector("a")).toBeNull();
+ });
+
it("connects only after the explicit action and keeps credentials in the iframe with no referrer", async () => {
await render(); await tick(60_000);
expect(computersApi.connect).not.toHaveBeenCalled();
diff --git a/ui/src/components/task-side-panel/TaskComputerPanel.tsx b/ui/src/components/task-side-panel/TaskComputerPanel.tsx
index 04d5d4c6feb..da3cb214dd0 100644
--- a/ui/src/components/task-side-panel/TaskComputerPanel.tsx
+++ b/ui/src/components/task-side-panel/TaskComputerPanel.tsx
@@ -23,19 +23,22 @@ function ComputerConnection({ issueId, environmentId, active = true }: ComputerP
const connectPending = useRef(false);
const [port, setPort] = useState("5173");
const [previewPending, setPreviewPending] = useState(false);
+ const [preview, setPreview] = useState<{ url: string; port: string } | null>(null);
async function openPreview() {
const previewWindow = window.open("about:blank", "_blank");
- if (!previewWindow) { setError("Allow a new tab to open the preview."); return; }
- previewWindow.opener = null;
+ if (previewWindow) previewWindow.opener = null;
setPreviewPending(true);
+ setPreview(null);
setError(null);
try {
const result = await computersApi.preview(issueId, environmentId, Number(port));
- if (mounted.current) previewWindow.location.replace(result.url);
- else previewWindow.close();
+ if (mounted.current) {
+ setPreview({ url: result.url, port });
+ previewWindow?.location.replace(result.url);
+ } else previewWindow?.close();
} catch (cause) {
- previewWindow.close();
+ previewWindow?.close();
if (mounted.current) setError(cause instanceof Error ? cause.message : "Could not open the preview.");
} finally { if (mounted.current) setPreviewPending(false); }
}
@@ -155,6 +158,8 @@ function ComputerConnection({ issueId, environmentId, active = true }: ComputerP
className="min-w-0 flex-1" onChange={event => setPort(event.target.value)} />
+ {preview && preview.port === port && Open port {preview.port} in a new tab}
;
}