authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 17:26:34-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 17:26:34-04:00
log28ddaf75917140e5e4355d4386bc5d14d9dad7b6
tree0f951a74053325f5f293733c54bf2279f32c7834
parent5a9b56291f82b9269346053594d8b5dfca736976
signature Signed by PGP key B5690EEEBB952194

fix(web): confirm closing agent-controlled browsers (#9272)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

3 files changed, 121 insertions(+), 20 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+39
......@@ -10,9 +10,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
1010
1111import type { Thread, ThreadShell } from "../types";
1212import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
13import type { RightPanelSurface } from "../rightPanelStore";
1314import {
1415 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
1516 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
17 agentControlledBrowserCloseConfirmation,
1618 branchMismatchKey,
1719 buildExpiredTerminalContextToastCopy,
1820 buildLoadingThreadFromShell,
......@@ -44,6 +46,43 @@ import {
4446 toolGroupConsumesUpwardNavigation,
4547} from "./ChatView.logic";
4648
49describe("agent browser close confirmation", () => {
50 const surfaces = [
51 { id: "browser:one", kind: "preview", resourceId: "tab-1" },
52 { id: "browser:two", kind: "preview", resourceId: "tab-2" },
53 { id: "diff", kind: "diff" },
54 ] satisfies RightPanelSurface[];
55
56 it("only warns for browsers under active agent control", () => {
57 expect(
58 agentControlledBrowserCloseConfirmation(surfaces, {
59 "tab-1": { controller: "none" },
60 "tab-2": { controller: "human" },
61 }),
62 ).toBeNull();
63
64 expect(
65 agentControlledBrowserCloseConfirmation([surfaces[0]!], {
66 "tab-1": { controller: "agent" },
67 }),
68 ).toBe(
69 [
70 "Close browser while the agent is using it?",
71 "The agent is actively controlling this browser. Closing it may interrupt the current browser action.",
72 ].join("\n"),
73 );
74 });
75
76 it("counts every agent-controlled browser in a bulk close", () => {
77 expect(
78 agentControlledBrowserCloseConfirmation(surfaces, {
79 "tab-1": { controller: "agent" },
80 "tab-2": { controller: "agent" },
81 }),
82 ).toContain("Close 2 browsers");
83 });
84});
85
4786describe("isVideoPreviewRequestCurrent", () => {
4887 it("rejects changed threads and replaced previews", () => {
4988 expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false);
apps/web/src/components/ChatView.logic.ts+25
......@@ -45,6 +45,8 @@ import {
4545import type { DraftThreadEnvMode } from "../composerDraftStore";
4646import type { ComposerSubmissionIntent } from "../composer-logic";
4747import type { TimelineEntry } from "../session-logic";
48import type { DesktopPreviewOverlay } from "../previewStateStore";
49import type { RightPanelSurface } from "../rightPanelStore";
4850
4951export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
5052export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
......@@ -53,6 +55,29 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
5355
5456export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
5557
58export function agentControlledBrowserCloseConfirmation(
59 surfaces: readonly RightPanelSurface[],
60 desktopByTabId: Readonly<Record<string, Pick<DesktopPreviewOverlay, "controller"> | undefined>>,
61): string | null {
62 const activeBrowserCount = surfaces.filter(
63 (surface) =>
64 surface.kind === "preview" &&
65 surface.resourceId !== null &&
66 desktopByTabId[surface.resourceId]?.controller === "agent",
67 ).length;
68 if (activeBrowserCount === 0) return null;
69 if (activeBrowserCount === 1) {
70 return [
71 "Close browser while the agent is using it?",
72 "The agent is actively controlling this browser. Closing it may interrupt the current browser action.",
73 ].join("\n");
74 }
75 return [
76 `Close ${activeBrowserCount} browsers while the agent is using them?`,
77 "The agent is actively controlling these browsers. Closing them may interrupt the current browser actions.",
78 ].join("\n");
79}
80
5681export function codexArtifactTemplatePromptToAppend(
5782 currentDraft: string,
5883 template: CodexArtifactTemplate,
apps/web/src/components/ChatView.tsx+57-20
......@@ -343,6 +343,7 @@ import {
343343} from "./chat/draftHeroTransition";
344344import {
345345 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
346 agentControlledBrowserCloseConfirmation,
346347 branchMismatchKey,
347348 buildExpiredTerminalContextToastCopy,
348349 buildLocalDraftThread,
......@@ -3872,6 +3873,27 @@ function ChatViewContent(props: ChatViewProps) {
38723873 storeCloseTerminal,
38733874 ],
38743875 );
3876 const closeAfterAgentBrowserConfirmation = useCallback(
3877 (surfaces: readonly RightPanelSurface[], closeSurfaces: () => void) => {
3878 const message = agentControlledBrowserCloseConfirmation(
3879 surfaces,
3880 activePreviewState.desktopByTabId,
3881 );
3882 if (!message) {
3883 closeSurfaces();
3884 return;
3885 }
3886 const localApi = readLocalApi();
3887 if (!localApi) return;
3888 void localApi.dialogs.confirm(message, { variant: "destructive" }).then(
3889 (confirmed) => {
3890 if (confirmed) closeSurfaces();
3891 },
3892 () => undefined,
3893 );
3894 },
3895 [activePreviewState.desktopByTabId],
3896 );
38753897 const syncActivePreviewSurface = useCallback(() => {
38763898 if (!activeThreadRef) return;
38773899 const nextActiveSurface = selectActiveRightPanelSurface(
......@@ -3882,14 +3904,26 @@ function ChatViewContent(props: ChatViewProps) {
38823904 setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId);
38833905 }
38843906 }, [activeThreadRef]);
3907 const finishRightPanelSurfaceClose = useCallback(
3908 (surfaces: readonly RightPanelSurface[]) => {
3909 if (!activeThreadRef) return;
3910 cleanupRightPanelSurfaces(surfaces);
3911 const store = useRightPanelStore.getState();
3912 for (const surface of surfaces) {
3913 store.closeSurface(activeThreadRef, surface.id);
3914 }
3915 syncActivePreviewSurface();
3916 },
3917 [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface],
3918 );
38853919 const closeRightPanelSurface = useCallback(
38863920 (surface: RightPanelSurface) => {
38873921 if (!activeThreadRef) return;
3888 const finishClose = () => {
3889 cleanupRightPanelSurfaces([surface]);
3890 useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id);
3891 syncActivePreviewSurface();
3892 };
3922 const finishClose = () => finishRightPanelSurfaceClose([surface]);
3923 if (surface.kind === "preview") {
3924 closeAfterAgentBrowserConfirmation([surface], finishClose);
3925 return;
3926 }
38933927 if (surface.kind !== "terminal") {
38943928 finishClose();
38953929 return;
......@@ -3909,23 +3943,22 @@ function ChatViewContent(props: ChatViewProps) {
39093943 [
39103944 activeThreadRef,
39113945 activeTerminalLabelsById,
3912 cleanupRightPanelSurfaces,
3913 syncActivePreviewSurface,
3946 closeAfterAgentBrowserConfirmation,
3947 finishRightPanelSurfaceClose,
39143948 ],
39153949 );
39163950 const closeOtherRightPanelSurfaces = useCallback(
39173951 (surface: RightPanelSurface) => {
39183952 if (!activeThreadRef) return;
39193953 const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id);
3920 cleanupRightPanelSurfaces(surfaces);
3921 useRightPanelStore.getState().closeOtherSurfaces(activeThreadRef, surface.id);
3922 syncActivePreviewSurface();
3954 const finishClose = () => finishRightPanelSurfaceClose(surfaces);
3955 closeAfterAgentBrowserConfirmation(surfaces, finishClose);
39233956 },
39243957 [
39253958 activeThreadRef,
3926 cleanupRightPanelSurfaces,
3959 closeAfterAgentBrowserConfirmation,
3960 finishRightPanelSurfaceClose,
39273961 rightPanelState.surfaces,
3928 syncActivePreviewSurface,
39293962 ],
39303963 );
39313964 const closeRightPanelSurfacesToRight = useCallback(
......@@ -3934,22 +3967,26 @@ function ChatViewContent(props: ChatViewProps) {
39343967 const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id);
39353968 if (surfaceIndex < 0) return;
39363969 const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1);
3937 cleanupRightPanelSurfaces(surfaces);
3938 useRightPanelStore.getState().closeSurfacesToRight(activeThreadRef, surface.id);
3939 syncActivePreviewSurface();
3970 const finishClose = () => finishRightPanelSurfaceClose(surfaces);
3971 closeAfterAgentBrowserConfirmation(surfaces, finishClose);
39403972 },
39413973 [
39423974 activeThreadRef,
3943 cleanupRightPanelSurfaces,
3975 closeAfterAgentBrowserConfirmation,
3976 finishRightPanelSurfaceClose,
39443977 rightPanelState.surfaces,
3945 syncActivePreviewSurface,
39463978 ],
39473979 );
39483980 const closeAllRightPanelSurfaces = useCallback(() => {
39493981 if (!activeThreadRef) return;
3950 cleanupRightPanelSurfaces(rightPanelState.surfaces);
3951 useRightPanelStore.getState().closeAllSurfaces(activeThreadRef);
3952 }, [activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces]);
3982 const finishClose = () => finishRightPanelSurfaceClose(rightPanelState.surfaces);
3983 closeAfterAgentBrowserConfirmation(rightPanelState.surfaces, finishClose);
3984 }, [
3985 activeThreadRef,
3986 closeAfterAgentBrowserConfirmation,
3987 finishRightPanelSurfaceClose,
3988 rightPanelState.surfaces,
3989 ]);
39533990 const copyRightPanelFilePath = useCallback((relativePath: string) => {
39543991 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
39553992 toastManager.add(