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"...@@ -10,9 +10,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
1010
11import type { Thread, ThreadShell } from "../types";11import type { Thread, ThreadShell } from "../types";
12import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";12import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
13import type { RightPanelSurface } from "../rightPanelStore";
13import {14import {
14 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,15 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
15 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,16 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
17 agentControlledBrowserCloseConfirmation,
16 branchMismatchKey,18 branchMismatchKey,
17 buildExpiredTerminalContextToastCopy,19 buildExpiredTerminalContextToastCopy,
18 buildLoadingThreadFromShell,20 buildLoadingThreadFromShell,
...@@ -44,6 +46,43 @@ import {...@@ -44,6 +46,43 @@ import {
44 toolGroupConsumesUpwardNavigation,46 toolGroupConsumesUpwardNavigation,
45} from "./ChatView.logic";47} 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
47describe("isVideoPreviewRequestCurrent", () => {86describe("isVideoPreviewRequestCurrent", () => {
48 it("rejects changed threads and replaced previews", () => {87 it("rejects changed threads and replaced previews", () => {
49 expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false);88 expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false);
apps/web/src/components/ChatView.logic.ts+25
...@@ -45,6 +45,8 @@ import {...@@ -45,6 +45,8 @@ import {
45import type { DraftThreadEnvMode } from "../composerDraftStore";45import type { DraftThreadEnvMode } from "../composerDraftStore";
46import type { ComposerSubmissionIntent } from "../composer-logic";46import type { ComposerSubmissionIntent } from "../composer-logic";
47import type { TimelineEntry } from "../session-logic";47import type { TimelineEntry } from "../session-logic";
48import type { DesktopPreviewOverlay } from "../previewStateStore";
49import type { RightPanelSurface } from "../rightPanelStore";
4850
49export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";51export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
50export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;52export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
...@@ -53,6 +55,29 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;...@@ -53,6 +55,29 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
5355
54export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);56export 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
56export function codexArtifactTemplatePromptToAppend(81export function codexArtifactTemplatePromptToAppend(
57 currentDraft: string,82 currentDraft: string,
58 template: CodexArtifactTemplate,83 template: CodexArtifactTemplate,
apps/web/src/components/ChatView.tsx+57-20
...@@ -343,6 +343,7 @@ import {...@@ -343,6 +343,7 @@ import {
343} from "./chat/draftHeroTransition";343} from "./chat/draftHeroTransition";
344import {344import {
345 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,345 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
346 agentControlledBrowserCloseConfirmation,
346 branchMismatchKey,347 branchMismatchKey,
347 buildExpiredTerminalContextToastCopy,348 buildExpiredTerminalContextToastCopy,
348 buildLocalDraftThread,349 buildLocalDraftThread,
...@@ -3872,6 +3873,27 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3872,6 +3873,27 @@ function ChatViewContent(props: ChatViewProps) {
3872 storeCloseTerminal,3873 storeCloseTerminal,
3873 ],3874 ],
3874 );3875 );
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 );
3875 const syncActivePreviewSurface = useCallback(() => {3897 const syncActivePreviewSurface = useCallback(() => {
3876 if (!activeThreadRef) return;3898 if (!activeThreadRef) return;
3877 const nextActiveSurface = selectActiveRightPanelSurface(3899 const nextActiveSurface = selectActiveRightPanelSurface(
...@@ -3882,14 +3904,26 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3882,14 +3904,26 @@ function ChatViewContent(props: ChatViewProps) {
3882 setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId);3904 setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId);
3883 }3905 }
3884 }, [activeThreadRef]);3906 }, [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 );
3885 const closeRightPanelSurface = useCallback(3919 const closeRightPanelSurface = useCallback(
3886 (surface: RightPanelSurface) => {3920 (surface: RightPanelSurface) => {
3887 if (!activeThreadRef) return;3921 if (!activeThreadRef) return;
3888 const finishClose = () => {3922 const finishClose = () => finishRightPanelSurfaceClose([surface]);
3889 cleanupRightPanelSurfaces([surface]);3923 if (surface.kind === "preview") {
3890 useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id);3924 closeAfterAgentBrowserConfirmation([surface], finishClose);
3891 syncActivePreviewSurface();3925 return;
3892 };3926 }
3893 if (surface.kind !== "terminal") {3927 if (surface.kind !== "terminal") {
3894 finishClose();3928 finishClose();
3895 return;3929 return;
...@@ -3909,23 +3943,22 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3909,23 +3943,22 @@ function ChatViewContent(props: ChatViewProps) {
3909 [3943 [
3910 activeThreadRef,3944 activeThreadRef,
3911 activeTerminalLabelsById,3945 activeTerminalLabelsById,
3912 cleanupRightPanelSurfaces,3946 closeAfterAgentBrowserConfirmation,
3913 syncActivePreviewSurface,3947 finishRightPanelSurfaceClose,
3914 ],3948 ],
3915 );3949 );
3916 const closeOtherRightPanelSurfaces = useCallback(3950 const closeOtherRightPanelSurfaces = useCallback(
3917 (surface: RightPanelSurface) => {3951 (surface: RightPanelSurface) => {
3918 if (!activeThreadRef) return;3952 if (!activeThreadRef) return;
3919 const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id);3953 const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id);
3920 cleanupRightPanelSurfaces(surfaces);3954 const finishClose = () => finishRightPanelSurfaceClose(surfaces);
3921 useRightPanelStore.getState().closeOtherSurfaces(activeThreadRef, surface.id);3955 closeAfterAgentBrowserConfirmation(surfaces, finishClose);
3922 syncActivePreviewSurface();
3923 },3956 },
3924 [3957 [
3925 activeThreadRef,3958 activeThreadRef,
3926 cleanupRightPanelSurfaces,3959 closeAfterAgentBrowserConfirmation,
3960 finishRightPanelSurfaceClose,
3927 rightPanelState.surfaces,3961 rightPanelState.surfaces,
3928 syncActivePreviewSurface,
3929 ],3962 ],
3930 );3963 );
3931 const closeRightPanelSurfacesToRight = useCallback(3964 const closeRightPanelSurfacesToRight = useCallback(
...@@ -3934,22 +3967,26 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3934,22 +3967,26 @@ function ChatViewContent(props: ChatViewProps) {
3934 const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id);3967 const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id);
3935 if (surfaceIndex < 0) return;3968 if (surfaceIndex < 0) return;
3936 const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1);3969 const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1);
3937 cleanupRightPanelSurfaces(surfaces);3970 const finishClose = () => finishRightPanelSurfaceClose(surfaces);
3938 useRightPanelStore.getState().closeSurfacesToRight(activeThreadRef, surface.id);3971 closeAfterAgentBrowserConfirmation(surfaces, finishClose);
3939 syncActivePreviewSurface();
3940 },3972 },
3941 [3973 [
3942 activeThreadRef,3974 activeThreadRef,
3943 cleanupRightPanelSurfaces,3975 closeAfterAgentBrowserConfirmation,
3976 finishRightPanelSurfaceClose,
3944 rightPanelState.surfaces,3977 rightPanelState.surfaces,
3945 syncActivePreviewSurface,
3946 ],3978 ],
3947 );3979 );
3948 const closeAllRightPanelSurfaces = useCallback(() => {3980 const closeAllRightPanelSurfaces = useCallback(() => {
3949 if (!activeThreadRef) return;3981 if (!activeThreadRef) return;
3950 cleanupRightPanelSurfaces(rightPanelState.surfaces);3982 const finishClose = () => finishRightPanelSurfaceClose(rightPanelState.surfaces);
3951 useRightPanelStore.getState().closeAllSurfaces(activeThreadRef);3983 closeAfterAgentBrowserConfirmation(rightPanelState.surfaces, finishClose);
3952 }, [activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces]);3984 }, [
3985 activeThreadRef,
3986 closeAfterAgentBrowserConfirmation,
3987 finishRightPanelSurfaceClose,
3988 rightPanelState.surfaces,
3989 ]);
3953 const copyRightPanelFilePath = useCallback((relativePath: string) => {3990 const copyRightPanelFilePath = useCallback((relativePath: string) => {
3954 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {3991 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
3955 toastManager.add(3992 toastManager.add(