From 0f1df92f7eb1a778fe0ed3f79b54ebac023cb890 Mon Sep 17 00:00:00 2001
From: David McKay
Date: Sat, 26 Sep 2026 16:54:53 -0700
Subject: [PATCH 1/2] Show browser ownership controls in chat and Computer
sidebar
---
CHANGELOG.md | 7 +
.../components/computer/computer-controls.tsx | 100 ++++++
.../components/computer/computer-panel.tsx | 21 ++
app/src/components/computer/computer-view.tsx | 208 +++--------
app/src/lib/computers/use-control.ts | 149 ++++++++
app/src/routes/_authed/_app/bot.tsx | 174 +++++----
.../_authed/_app/channel/$channelId.tsx | 76 +---
app/tests/bot-route-default-agent.fixture.tsx | 30 +-
app/tests/bot-route-default-agent.test.tsx | 118 +++++-
app/tests/computer-control-button.test.tsx | 338 ++++++++++++++++++
10 files changed, 913 insertions(+), 308 deletions(-)
create mode 100644 app/src/components/computer/computer-controls.tsx
create mode 100644 app/src/components/computer/computer-panel.tsx
create mode 100644 app/src/lib/computers/use-control.ts
create mode 100644 app/tests/computer-control-button.test.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72d9f52d1..a8aa3da88 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,13 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
## Unreleased
+### Browser controls are visible in chat and the Computer sidebar
+
+Channel chats and standalone Bot chats now have a labeled Computer button and always-visible
+Take control or Hand back controls. The same ownership state is shown in the chat, live Computer
+sidebar, and full-size viewer. Standalone Bot chats can open the current live browser alongside
+the conversation without losing the selected Bot or chat history.
+
### Browser challenges can be handed to a person without losing the Bot's page
Bots pause for actionable browser challenges and resume from a fresh page snapshot after an explicit
diff --git a/app/src/components/computer/computer-controls.tsx b/app/src/components/computer/computer-controls.tsx
new file mode 100644
index 000000000..ec9208dc8
--- /dev/null
+++ b/app/src/components/computer/computer-controls.tsx
@@ -0,0 +1,100 @@
+import { useEffect, useRef } from "react";
+import { IconDeviceDesktop } from "@tabler/icons-react";
+import { Button } from "@/components/ui/button";
+import { useComputerControl } from "@/lib/computers/use-control";
+
+/** The same ownership action appears in chat, the computer sidebar, and the full-size viewer. */
+export function ComputerControlButton({
+ computerId,
+ onTakeControl,
+}: {
+ computerId: string;
+ onTakeControl?: () => void;
+}) {
+ const { control, busy, problem, change } = useComputerControl(computerId);
+ const human = control?.holder === "human";
+ return (
+
+ {
+ if (await change?.(human ? "release" : "take")) {
+ if (!human) onTakeControl?.();
+ }
+ }}
+ >
+ {human ? "Hand back" : "Take control"}
+
+ {control?.transitioning ? (
+
+ Finishing the current action…
+
+ ) : null}
+ {problem ? (
+
+ {problem}
+
+ ) : null}
+
+ );
+}
+
+export function ComputerChatControls({
+ computerId,
+ open,
+ onOpenChange,
+}: {
+ computerId?: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const { control } = useComputerControl(computerId ?? "", Boolean(computerId));
+ const needsYou = Boolean(
+ control &&
+ (control.requested ||
+ control.holder === "human" ||
+ control.request?.status === "interrupted" ||
+ control.secretWanted !== undefined),
+ );
+ const promptKey = needsYou
+ ? `${computerId}:${control?.secretWanted ?? control?.request?.id ?? "human"}`
+ : null;
+ const shownPrompt = useRef(null);
+ useEffect(() => {
+ if (shownPrompt.current === promptKey) return;
+ shownPrompt.current = promptKey;
+ // Surface each new prompt once; closing the Computer remains a real dismissal.
+ if (promptKey) onOpenChange(true);
+ }, [promptKey, onOpenChange]);
+ return (
+
+ onOpenChange(!open)}
+ >
+
+ Computer
+ {needsYou ? (
+
+ ) : null}
+
+ {computerId ? (
+ onOpenChange(true)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/app/src/components/computer/computer-panel.tsx b/app/src/components/computer/computer-panel.tsx
new file mode 100644
index 000000000..d9dc71509
--- /dev/null
+++ b/app/src/components/computer/computer-panel.tsx
@@ -0,0 +1,21 @@
+import { ActivityLog } from "./activity-log";
+import { ComputerView } from "./computer-view";
+
+/** The current live computer, separate from screenshots kept in historical tool results. */
+export function ComputerViewPanel({
+ agentId,
+ name,
+}: {
+ agentId: string;
+ name?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/app/src/components/computer/computer-view.tsx b/app/src/components/computer/computer-view.tsx
index f3b960f02..21d9ac616 100644
--- a/app/src/components/computer/computer-view.tsx
+++ b/app/src/components/computer/computer-view.tsx
@@ -1,13 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
-import {
- cancelControl,
- type ControlState,
- readControl,
- releaseControl,
- supplySecret,
- takeControl,
-} from "@/lib/computers/control";
+import { supplySecret } from "@/lib/computers/control";
+import { useComputerControl } from "@/lib/computers/use-control";
+import { ComputerControlButton } from "./computer-controls";
import {
readPageFrame,
readScreenshot,
@@ -227,69 +222,6 @@ export function ComputerView({
const [expanded, setExpanded] = useState(false);
const [streamProblem, setStreamProblem] = useState(null);
const [retryKey, setRetryKey] = useState(0);
- const [controlProblem, setControlProblem] = useState(null);
- const [changingControl, setChangingControl] = useState(false);
- const [control, setControl] = useState(null);
- /** Held only until it is sent. Never lifted into a URL, a log, or anything that outlives this form. */
- const [secret, setSecret] = useState("");
- const [secretProblem, setSecretProblem] = useState(null);
- const [sendingSecret, setSendingSecret] = useState(false);
- const pageVisible = usePageVisible();
- const [previewRef, previewIntersecting] = useElementVisible();
- const driving = control?.holder === "human" && !control.transitioning;
- /** Read by the polling loop without restarting it on control changes. */
- const drivingRef = useRef(false);
- drivingRef.current = driving;
-
- const changeControl = async (action: "take" | "release" | "cancel") => {
- if (changingControl) return;
- const requestId = control?.request?.id;
- if (action !== "take" && !requestId) {
- setControlProblem(
- "The handoff request could not be identified. Retry the connection.",
- );
- return;
- }
- setChangingControl(true);
- setControlProblem(null);
- try {
- const state =
- action === "take"
- ? await takeControl(
- computerId,
- control?.requested ? requestId : undefined,
- )
- : requestId
- ? action === "release"
- ? await releaseControl(computerId, requestId)
- : await cancelControl(computerId, requestId)
- : null;
- if (!state)
- setControlProblem(
- "Control could not be changed. Check the connection and retry.",
- );
- else {
- setControl(state);
- if (action === "take") setExpanded(true);
- }
- } catch {
- setControlProblem(
- "The computer could not be reached. Retry when it reconnects.",
- );
- } finally {
- setChangingControl(false);
- }
- };
- const handBack = () => changeControl("release");
- /** Secret prompts keep the screen live even though the human does not hold the wheel. */
- const secretPending = Boolean(control?.secretWanted);
- const secretPendingRef = useRef(false);
- secretPendingRef.current = secretPending;
- // Held in a ref so a slow response cannot overwrite a newer frame after the component moved on.
- const generation = useRef(0);
- /** Force a short watch window after non-Bot actions such as secret entry. */
- const watchUntil = useRef(0);
-
/**
* A finished turn is history, and history is not polled.
*
@@ -320,6 +252,32 @@ export function ComputerView({
const [, setFrameArrived] = useState(0);
const settled = !active && (finished || Boolean(knownPage));
+ const {
+ control,
+ busy: changingControl,
+ change: changeControl,
+ refresh: refreshControl,
+ } = useComputerControl(computerId, !settled);
+ /** Held only until it is sent. Never lifted into a URL, a log, or anything that outlives this form. */
+ const [secret, setSecret] = useState("");
+ const [secretProblem, setSecretProblem] = useState(null);
+ const [sendingSecret, setSendingSecret] = useState(false);
+ const pageVisible = usePageVisible();
+ const [previewRef, previewIntersecting] = useElementVisible();
+ const driving = control?.holder === "human" && !control.transitioning;
+ /** Read by the polling loop without restarting it on control changes. */
+ const drivingRef = useRef(false);
+ drivingRef.current = driving;
+
+ /** Secret prompts keep the screen live even though the human does not hold the wheel. */
+ const secretPending = Boolean(control?.secretWanted);
+ const secretPendingRef = useRef(false);
+ secretPendingRef.current = secretPending;
+ // Held in a ref so a slow response cannot overwrite a newer frame after the component moved on.
+ const generation = useRef(0);
+ /** Force a short watch window after non-Bot actions such as secret entry. */
+ const watchUntil = useRef(0);
+
const visualVisible = pageVisible && (expanded || previewIntersecting);
/*
@@ -411,32 +369,6 @@ export function ComputerView({
};
}, [computerId, active, intervalMs, secretPending, settled, visualVisible]);
- /** Poll control state independently from screenshot polling so help/secret prompts surface. */
- useEffect(() => {
- if (settled) return;
- let live = true;
- let timer: ReturnType;
- const tick = async () => {
- const state = await readControl(computerId).catch(() => null);
- if (!live) return;
- setControl(state);
- if (state)
- setControlProblem((previous) =>
- previous?.startsWith("Reconnecting:") ? null : previous,
- );
- if (!state)
- setControlProblem(
- "Reconnecting: browser ownership could not be checked. Input is paused.",
- );
- timer = setTimeout(tick, 1000);
- };
- void tick();
- return () => {
- live = false;
- clearTimeout(timer);
- };
- }, [computerId, settled]);
-
// Input forwarding lives in LiveScreen on the socket.
// Escape is bound to the window so it works regardless of overlay focus.
useEffect(() => {
@@ -550,30 +482,13 @@ export function ComputerView({
)}
- {/*
- * The Bot ASKING for the wheel, which is not the same thing as a person wanting it.
- *
- * The standing "who is driving" prose and the everyday Take control button live in the
- * full-size view, where there is a page big enough to drive. This row is the exception: a
- * request is an exceptional state with a reason attached, it is the one moment the screen is
- * waiting on a person rather than the other way round, and making them open the full-size
- * view to find out what was wanted would hide the reason behind a click. Taking the wheel
- * from here opens that view, because driving is what they are being asked to do.
- */}
+ {/* The request reason appears above the always-available ownership controls. */}
{!driving && !settled && control?.requested ? (
The assistant needs you. {" "}
{control.reason}
- void changeControl("take")}
- className="shrink-0 rounded-md bg-primary px-3 py-1 font-medium text-primary-foreground text-xs"
- >
- Take control
-
) : null}
@@ -583,7 +498,7 @@ export function ComputerView({
type="button"
className="underline"
disabled={changingControl}
- onClick={() => void changeControl("cancel")}
+ onClick={() => void changeControl?.("cancel")}
>
Cancel request
@@ -600,11 +515,6 @@ export function ComputerView({
screen and take control again to check it.
) : null}
- {!settled && controlProblem ? (
-
@@ -660,12 +569,23 @@ export function ComputerView({
) : null}
- {/*
- * The inline card carries no persistent footer: taking the wheel, handing it back, and the
- * standing "who is driving" prose all live in the full-size view, where there is a page big
- * enough to drive. The two rows above appear only while the Bot is stuck — waiting on a
- * credential, or asking for the wheel — and go again when it is not.
- */}
+ {!settled ? (
+
+
+ {!control
+ ? "Checking who has control…"
+ : control.transitioning
+ ? "Finishing the current action…"
+ : driving
+ ? "You have control. Open the screen to click and type."
+ : "The assistant has control."}
+
+ setExpanded(true)}
+ />
+
+ ) : null}
{/*
@@ -764,14 +684,6 @@ export function ComputerView({
offering control of whatever the Bot has open now. Those sentences are about the
present and this view is a record; a record does not get a steering wheel.
*/}
- {controlProblem ? (
-
- {controlProblem}
-
- ) : null}
{control?.transitioning ? (
Finishing the current action before giving you control…
@@ -803,27 +715,7 @@ export function ComputerView({
) : null}
- {driving ? (
- void handBack()}
- className="shrink-0 rounded-md bg-primary px-3 py-1.5 font-medium text-primary-foreground text-sm"
- >
- Hand back
-
- ) : (
- void changeControl("take")}
- className="shrink-0 rounded-md border px-3 py-1.5 font-medium text-sm"
- >
- Take control
-
- )}
+
)}
diff --git a/app/src/lib/computers/use-control.ts b/app/src/lib/computers/use-control.ts
new file mode 100644
index 000000000..aa529ac55
--- /dev/null
+++ b/app/src/lib/computers/use-control.ts
@@ -0,0 +1,149 @@
+import { useSyncExternalStore } from "react";
+import {
+ cancelControl,
+ type ControlState,
+ readControl,
+ releaseControl,
+ takeControl,
+} from "./control";
+
+type Action = "take" | "release" | "cancel";
+type Snapshot = {
+ control: ControlState | null;
+ busy: boolean;
+ problem: string | null;
+};
+const EMPTY: Snapshot = { control: null, busy: false, problem: null };
+type ControlStore = {
+ getSnapshot: () => Snapshot;
+ refresh: () => Promise;
+ change: (action: Action) => Promise;
+ subscribe: (listener: () => void) => () => void;
+};
+const stores = new Map();
+
+/** One authoritative poll and mutation gate per visible Bot, shared by chat, sidebar and modal. */
+function createControlStore(computerId: string): ControlStore {
+ let snapshot = EMPTY;
+ let version = 0;
+ let timer: ReturnType | undefined;
+ let reading: AbortController | undefined;
+ const listeners = new Set<() => void>();
+ const publish = (next: Snapshot) => {
+ snapshot = next;
+ for (const listener of listeners) listener();
+ };
+ const refresh = async () => {
+ const mine = version;
+ reading?.abort();
+ const abort = new AbortController();
+ reading = abort;
+ const control = await readControl(
+ computerId,
+ undefined,
+ abort.signal,
+ ).catch(() => null);
+ if (reading === abort) reading = undefined;
+ if (abort.signal.aborted || mine !== version || snapshot.busy) return;
+ publish({
+ ...snapshot,
+ control,
+ problem: control
+ ? snapshot.problem?.startsWith("Reconnecting:")
+ ? null
+ : snapshot.problem
+ : "Reconnecting: browser ownership could not be checked. Input is paused.",
+ });
+ };
+ // Keep the same store through Strict Mode resubscriptions and pending mutations.
+ const disposeWhenUnused = () =>
+ queueMicrotask(() => {
+ if (!listeners.size && !snapshot.busy && stores.get(computerId) === store)
+ stores.delete(computerId);
+ });
+ const change = async (action: Action): Promise => {
+ if (snapshot.busy || snapshot.control?.transitioning) return false;
+ const control = snapshot.control;
+ const requestId = control?.request?.id;
+ if (action !== "take" && !requestId) {
+ publish({
+ ...snapshot,
+ problem:
+ "The handoff request could not be identified. Retry the connection.",
+ });
+ return false;
+ }
+ version++;
+ reading?.abort();
+ publish({ ...snapshot, busy: true, problem: null });
+ try {
+ const next =
+ action === "take"
+ ? await takeControl(
+ computerId,
+ control?.requested ? requestId : undefined,
+ )
+ : requestId
+ ? action === "release"
+ ? await releaseControl(computerId, requestId)
+ : await cancelControl(computerId, requestId)
+ : null;
+ publish({
+ control: next ?? control,
+ busy: false,
+ problem: next
+ ? null
+ : "Control could not be changed. Check the connection and retry.",
+ });
+ return next !== null;
+ } catch {
+ publish({
+ ...snapshot,
+ busy: false,
+ problem: "The computer could not be reached. Retry when it reconnects.",
+ });
+ return false;
+ } finally {
+ disposeWhenUnused();
+ }
+ };
+ const store: ControlStore = {
+ getSnapshot: () => snapshot,
+ refresh,
+ change,
+ subscribe: (listener: () => void) => {
+ listeners.add(listener);
+ if (timer === undefined) {
+ if (!snapshot.busy) void refresh();
+ timer = setInterval(() => {
+ if (!snapshot.busy && !reading) void refresh();
+ }, 1000);
+ }
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size) return;
+ clearInterval(timer);
+ timer = undefined;
+ reading?.abort();
+ version++;
+ disposeWhenUnused();
+ };
+ },
+ };
+ return store;
+}
+const noSubscription = () => () => undefined;
+const emptySnapshot = () => EMPTY;
+export function useComputerControl(computerId: string, enabled = true) {
+ let store = enabled ? stores.get(computerId) : undefined;
+ if (enabled && !store) {
+ store = createControlStore(computerId);
+ stores.set(computerId, store);
+ }
+ const snapshot = useSyncExternalStore(
+ store?.subscribe ?? noSubscription,
+ store?.getSnapshot ?? emptySnapshot,
+ emptySnapshot,
+ );
+ return { ...snapshot, change: store?.change, refresh: store?.refresh };
+}
diff --git a/app/src/routes/_authed/_app/bot.tsx b/app/src/routes/_authed/_app/bot.tsx
index 7a666cf3f..128eb0197 100644
--- a/app/src/routes/_authed/_app/bot.tsx
+++ b/app/src/routes/_authed/_app/bot.tsx
@@ -2,6 +2,9 @@ import { CopilotChat } from "@copilotkit/react-core/v2";
import { IconPlus } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
+import { ComputerChatControls } from "@/components/computer/computer-controls";
+import { ComputerViewPanel } from "@/components/computer/computer-panel";
+import { DetailPanel } from "@/components/layout/detail-panel";
import { SidebarToggleBar } from "@/components/layout/sidebar-toggle";
import { Button } from "@/components/ui/button";
import { defaultAgentId } from "@/lib/agents/default-agent";
@@ -17,8 +20,11 @@ import { useStoppedTurn } from "@/lib/copilot/stopped-turn";
export const Route = createFileRoute("/_authed/_app/bot")({
component: RouteComponent,
- validateSearch: (search: Record): { agent?: string } => ({
+ validateSearch: (
+ search: Record,
+ ): { agent?: string; watch?: boolean } => ({
...(typeof search.agent === "string" ? { agent: search.agent } : {}),
+ ...(search.watch === true ? { watch: true } : {}),
}),
});
@@ -145,84 +151,104 @@ function BotChat({ agentId, name }: { agentId: string; name: string }) {
* a provider this app does not mount.
*/
const stopped = useStoppedTurn(agentId);
+ const { watch } = Route.useSearch();
+ const navigate = Route.useNavigate();
+ const showComputer = (open: boolean) =>
+ navigate({
+ search: (previous) => ({ ...previous, watch: open ? true : undefined }),
+ });
return (
-
-
-
-
- {/*
- * The Bot this screen is actually showing. A name written into the markup is wrong on
- * every deployment whose package did not happen to use it, which is the same defect the
- * route default above was fixed for: this screen called whichever Bot you opened
- * "Browser Bot", including the one named something else two lines of state away.
- */}
-
{name}
- {/*
- * Labelled rather than the bare icon button the sidebar uses for its own "start
- * something new" control: that one opens an empty screen, but this one throws away
- * whatever conversation is currently on screen, and a click with that consequence
- * deserves a word, not just a glyph.
- */}
-
-
- New chat
-
-
-
- Ask it to open a page and watch it work.
-
-
- {/*
- * Both banners render as plain siblings in this fixed order, never one nested inside the
- * other, so either can appear alone or both together without the layout jumping around
- * depending on which conditions are true.
- */}
- {history === "unavailable" ? (
-
- Earlier messages in this conversation could not be loaded, and the Bot
- is answering without them.
-
- ) : null}
- {/*
- * Under the header rather than at the end of the transcript, which is where the missing answer
- * was going to be and where the channel draws its own version of this. The packaged chat owns
- * that list and virtualises it, so reaching into it means replacing the whole message view and
- * taking on its scrolling. The cost of putting the sentence here instead is that it is not
- * beside the gap it explains; what it buys is that it is always on screen, whatever the
- * transcript has been scrolled to, and that it survives the next release of the chat.
- */}
- {stopped ? (
-
- {stopped}
-
- ) : null}
-
+
showComputer(false)}
+ detail={ }
+ >
+
+
+
+
+ {/*
+ * The Bot this screen is actually showing. A name written into the markup is wrong on
+ * every deployment whose package did not happen to use it, which is the same defect the
+ * route default above was fixed for: this screen called whichever Bot you opened
+ * "Browser Bot", including the one named something else two lines of state away.
+ */}
+
{name}
+ {/*
+ * Labelled rather than the bare icon button the sidebar uses for its own "start
+ * something new" control: that one opens an empty screen, but this one throws away
+ * whatever conversation is currently on screen, and a click with that consequence
+ * deserves a word, not just a glyph.
+ */}
+
+
+
+
+ New chat
+
+
+
+
+ Ask it to open a page and watch it work.
+
+
+ {/*
+ * Both banners render as plain siblings in this fixed order, never one nested inside the
+ * other, so either can appear alone or both together without the layout jumping around
+ * depending on which conditions are true.
+ */}
+ {history === "unavailable" ? (
+
+ Earlier messages in this conversation could not be loaded, and the
+ Bot is answering without them.
+
+ ) : null}
{/*
- * Keyed on the thread as well as the agent. Switching agents was already handled by
- * `agentId`, but `startNew` changes only the thread while the agent stays put, and the
- * packaged chat's own `startNewThread`/`setActiveThreadId` are proven no-ops once
- * `threadId` is a controlled prop (node_modules/@copilotkit/react-core/dist/copilotkit-
- * C4RqjAba.mjs:226-254): asking it to start over does nothing while it still holds the
- * old id. A key that omits the thread would leave the previous conversation on screen
- * under a composer that silently posts to the new one.
+ * Under the header rather than at the end of the transcript, which is where the missing answer
+ * was going to be and where the channel draws its own version of this. The packaged chat owns
+ * that list and virtualises it, so reaching into it means replacing the whole message view and
+ * taking on its scrolling. The cost of putting the sentence here instead is that it is not
+ * beside the gap it explains; what it buys is that it is always on screen, whatever the
+ * transcript has been scrolled to, and that it survives the next release of the chat.
*/}
- {threadId ? (
-
+ {stopped ? (
+
+ {stopped}
+
) : null}
+
+ {/*
+ * Keyed on the thread as well as the agent. Switching agents was already handled by
+ * `agentId`, but `startNew` changes only the thread while the agent stays put, and the
+ * packaged chat's own `startNewThread`/`setActiveThreadId` are proven no-ops once
+ * `threadId` is a controlled prop (node_modules/@copilotkit/react-core/dist/copilotkit-
+ * C4RqjAba.mjs:226-254): asking it to start over does nothing while it still holds the
+ * old id. A key that omits the thread would leave the previous conversation on screen
+ * under a composer that silently posts to the new one.
+ */}
+ {threadId ? (
+
+ ) : null}
+
-
+
);
}
diff --git a/app/src/routes/_authed/_app/channel/$channelId.tsx b/app/src/routes/_authed/_app/channel/$channelId.tsx
index d1bf998b5..596c4dc38 100644
--- a/app/src/routes/_authed/_app/channel/$channelId.tsx
+++ b/app/src/routes/_authed/_app/channel/$channelId.tsx
@@ -1,4 +1,4 @@
-import { IconDeviceDesktop, IconSettings } from "@tabler/icons-react";
+import { IconSettings } from "@tabler/icons-react";
import {
useInfiniteQuery,
useMutation,
@@ -13,9 +13,8 @@ import { AgentProfile } from "@/components/agents/agent-profile";
import { hasUnseenActivity } from "@/components/app-sidebar/app-sidebar";
import { ChannelAvatar } from "@/components/channels/avatar";
import { ChannelChat } from "@/components/channels/channel-chat";
-import { ActivityLog } from "@/components/computer/activity-log";
-import { ComputerView } from "@/components/computer/computer-view";
-import { useNeedsYou } from "@/components/computer/needs-you";
+import { ComputerChatControls } from "@/components/computer/computer-controls";
+import { ComputerViewPanel } from "@/components/computer/computer-panel";
import { DetailPanel } from "@/components/layout/detail-panel";
import { SidebarToggle } from "@/components/layout/sidebar-toggle";
import { Button } from "@/components/ui/button";
@@ -46,36 +45,6 @@ export const Route = createFileRoute("/_authed/_app/channel/$channelId")({
component: RouteComponent,
});
-/**
- * What the Bot is looking at, and what it is doing.
- *
- * Two surfaces, stacked rather than tabbed. The screen was the only window into a Bot's computer,
- * so a Bot that spent two minutes in a terminal showed a blank browser and nothing else: the honest
- * answer to "what is it doing" was "something, on a machine holding your logins". The activity —
- * the shell and the workspace — sits below the screen, so watching one never costs the other and
- * nothing about what the Bot is doing hides behind a tab nobody clicked.
- */
-function ComputerViewPanel({
- agentId,
- name,
-}: {
- agentId: string;
- name?: string;
-}) {
- return (
-
- );
-}
-
function RouteComponent() {
const { channelId } = Route.useParams();
const { settings, watch } = Route.useSearch();
@@ -86,8 +55,6 @@ function RouteComponent() {
const isWatching = watch === true;
/** Channel routing currently supports one coworker. */
const agentId = channel.data?.agentIds[0];
- /** Only polled while the screen is closed; the screen panel polls control itself. */
- const needsYou = useNeedsYou(agentId, !isWatching);
const queryClient = useQueryClient();
const markRead = useMutation(markChannelReadMutationOptions(queryClient));
@@ -118,16 +85,6 @@ function RouteComponent() {
}
}, [channelId, unseen, markReadMutate]);
- /*
- * Needs-you prompts auto-open the screen panel, because the prompt with the reason on it — the
- * amber "the assistant needs you" row, and the masked field for a credential — is drawn on the
- * screen card in that panel. Nothing about a stuck Bot is actionable until this pane is open.
- */
- useEffect(() => {
- if (!needsYou) return;
- show("watch");
- });
-
// Browser activity may auto-open the screen once per run unless this run was dismissed.
const dismissedEpoch = useRef
(null);
const runEpoch = useRef(null);
@@ -165,6 +122,7 @@ function RouteComponent() {
onClose={() => show(null)}
open={(isSettingsOpen || isWatching) && agentId !== undefined}
detailWidth={isWatching ? SCREEN_PANEL_WIDTH : undefined}
+ title={isWatching ? "Computer" : undefined}
detail={
agentId === undefined ? null : isWatching ? (
// Manual watch remains active even when there is no current browser action.
@@ -175,7 +133,7 @@ function RouteComponent() {
}
>
-
+
{/* Keyed on the displayed name so cold channel loads animate the resolved name, not the id. */}
@@ -216,25 +174,11 @@ function RouteComponent() {
-
show(isWatching ? null : "watch")}
- variant="ghost"
- size="icon"
- >
-
- {/* Mirrors needs-you state outside the hidden screen pane. */}
- {needsYou ? (
-
- ) : null}
-
+
show(open ? "watch" : null)}
+ />
({
- CopilotChat: ({ agentId }: { agentId: string; threadId?: string }) => (
-
+ CopilotChat: ({
+ agentId,
+ threadId,
+ }: {
+ agentId: string;
+ threadId?: string;
+ }) => (
+
+
+
),
}));
@@ -11,11 +24,14 @@ mock.module("@/lib/copilot/active-bot", () => ({
}));
mock.module("@/lib/copilot/bot-thread", () => ({
- useBotThread: (agentId: string) => ({
- history: "ready",
- startNew: () => undefined,
- threadId: `thread-${agentId}`,
- }),
+ useBotThread: (agentId: string) => {
+ const [revision, setRevision] = useState(0);
+ return {
+ history: "ready",
+ startNew: () => setRevision((value) => value + 1),
+ threadId: `thread-${agentId}-${revision}`,
+ };
+ },
}));
mock.module("@/lib/copilot/stopped-turn", () => ({
diff --git a/app/tests/bot-route-default-agent.test.tsx b/app/tests/bot-route-default-agent.test.tsx
index bba6e145b..5e9a175b5 100644
--- a/app/tests/bot-route-default-agent.test.tsx
+++ b/app/tests/bot-route-default-agent.test.tsx
@@ -11,17 +11,28 @@ import {
Outlet,
RouterProvider,
} from "@tanstack/react-router";
-import { cleanup, render } from "@testing-library/react";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ waitFor,
+ within,
+} from "@testing-library/react";
import { type AgentProfile, agentKeys } from "@/lib/agents/queries";
import { Route as BotRoute } from "@/routes/_authed/_app/bot";
-beforeAll(() => GlobalRegistrator.register());
+let originalRect: typeof HTMLElement.prototype.getBoundingClientRect;
+beforeAll(() => {
+ GlobalRegistrator.register();
+ originalRect = HTMLElement.prototype.getBoundingClientRect;
+});
const originalFetch = global.fetch;
afterEach(() => {
- global.fetch = originalFetch;
cleanup();
+ global.fetch = originalFetch;
+ HTMLElement.prototype.getBoundingClientRect = originalRect;
});
afterAll(() => GlobalRegistrator.unregister());
@@ -285,3 +296,104 @@ test("/bot still falls back to the first agent when no picked harness exists", a
"general-assistant",
);
});
+
+test("/bot opens a live Computer sidebar with ownership controls and preserves the chat", async () => {
+ HTMLElement.prototype.getBoundingClientRect = () =>
+ new DOMRect(0, 0, 1200, 800);
+ global.fetch = Object.assign(
+ async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/control"))
+ return Response.json({
+ holder: "bot",
+ since: "2026-09-26",
+ requested: false,
+ transitioning: false,
+ resumeSnapshotRequired: false,
+ });
+ return Response.json({ error: "No frame yet" }, { status: 404 });
+ },
+ { preconnect: originalFetch.preconnect },
+ );
+ const view = renderBot(
+ queryClientWithAgents([GENERAL_ASSISTANT, PICKED_HARNESS]),
+ "/bot?agent=general-assistant",
+ );
+ const toggle = await view.findByRole("button", { name: "Open Computer" });
+ await waitFor(() =>
+ expect(
+ view
+ .getByRole("button", { name: "Take control" })
+ .hasAttribute("disabled"),
+ ).toBe(false),
+ );
+ const chat = view.getByTestId("copilot-chat");
+ fireEvent.change(view.getByRole("textbox", { name: "Chat draft" }), {
+ target: { value: "Keep this conversation" },
+ });
+ fireEvent.click(toggle);
+ const sidebar = within(
+ await view.findByRole("region", { name: "Computer sidebar" }),
+ );
+ expect(
+ sidebar
+ .getByRole("button", { name: "Take control" })
+ .hasAttribute("disabled"),
+ ).toBe(false);
+ expect(
+ sidebar.getByRole("button", {
+ name: "Open the assistant's screen full size",
+ }),
+ ).toBeTruthy();
+ expect(sidebar.getByRole("heading", { name: "Activity" })).toBeTruthy();
+ fireEvent.click(view.getByRole("button", { name: "Close Computer" }));
+ await waitFor(() =>
+ expect(view.queryByRole("region", { name: "Computer sidebar" })).toBeNull(),
+ );
+ expect(view.getByTestId("copilot-chat")).toBe(chat);
+ expect(chat.dataset.agentId).toBe("general-assistant");
+ expect(view.getByDisplayValue("Keep this conversation")).toBeTruthy();
+ expect(view.getByRole("button", { name: "Open Computer" })).toBeTruthy();
+ fireEvent.click(view.getByRole("button", { name: "New chat" }));
+ await waitFor(() =>
+ expect(view.getByTestId("copilot-chat").dataset.threadId).not.toBe(
+ chat.dataset.threadId,
+ ),
+ );
+ expect(view.getByTestId("copilot-chat").dataset.agentId).toBe(
+ "general-assistant",
+ );
+});
+
+test("/bot?watch=true restores the live Computer panel on reload", async () => {
+ HTMLElement.prototype.getBoundingClientRect = () =>
+ new DOMRect(0, 0, 1200, 800);
+ global.fetch = Object.assign(
+ async () =>
+ Response.json({
+ holder: "bot",
+ since: "2026-09-26",
+ requested: false,
+ transitioning: false,
+ resumeSnapshotRequired: false,
+ }),
+ { preconnect: originalFetch.preconnect },
+ );
+ const view = renderBot(
+ queryClientWithAgents([GENERAL_ASSISTANT]),
+ "/bot?agent=general-assistant&watch=true",
+ );
+ const sidebar = within(
+ await view.findByRole("region", { name: "Computer sidebar" }),
+ );
+ await waitFor(() =>
+ expect(
+ sidebar
+ .getByRole("button", { name: "Take control" })
+ .hasAttribute("disabled"),
+ ).toBe(false),
+ );
+ expect(view.getByTestId("copilot-chat").dataset.agentId).toBe(
+ "general-assistant",
+ );
+});
diff --git a/app/tests/computer-control-button.test.tsx b/app/tests/computer-control-button.test.tsx
new file mode 100644
index 000000000..96ef68264
--- /dev/null
+++ b/app/tests/computer-control-button.test.tsx
@@ -0,0 +1,338 @@
+import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { StrictMode, useState } from "react";
+import type { ControlState } from "@/lib/computers/control";
+import {
+ ComputerChatControls,
+ ComputerControlButton,
+} from "@/components/computer/computer-controls";
+
+const originalFetch = globalThis.fetch;
+beforeAll(() => GlobalRegistrator.register());
+afterEach(() => {
+ cleanup();
+ globalThis.fetch = originalFetch;
+});
+afterAll(() => GlobalRegistrator.unregister());
+
+function server(initial: "bot" | "human" = "bot") {
+ let state: ControlState = {
+ holder: initial,
+ since: "2026-09-26T00:00:00Z",
+ requested: false,
+ transitioning: false,
+ resumeSnapshotRequired: false,
+ ...(initial === "human"
+ ? {
+ request: {
+ id: "request-1",
+ status: "taken" as const,
+ reason: "Please help",
+ source: "manual" as const,
+ createdAt: "2026-09-26T00:00:00Z",
+ updatedAt: "2026-09-26T00:00:00Z",
+ },
+ }
+ : {}),
+ };
+ const calls: { path: string; body: unknown }[] = [];
+ let fail = false;
+ let completeTake: (() => void) | undefined;
+ let holdTake = false;
+ globalThis.fetch = Object.assign(
+ async (
+ url: Parameters[0],
+ init?: Parameters[1],
+ ) => {
+ const path = String(url);
+ const body: unknown = init?.body
+ ? JSON.parse(String(init.body))
+ : undefined;
+ calls.push({ path, body });
+ if (fail && path.endsWith("/control/take"))
+ return Response.json({ error: "Unavailable" }, { status: 503 });
+ if (path.endsWith("/control/request"))
+ state = {
+ ...state,
+ requested: true,
+ request: {
+ id: "request-1",
+ status: "waiting",
+ reason: "Manual control",
+ source: "manual",
+ createdAt: state.since,
+ updatedAt: state.since,
+ },
+ };
+ if (path.endsWith("/control/take")) {
+ if (holdTake)
+ await new Promise((resolve) => {
+ completeTake = resolve;
+ });
+ if (state.request)
+ state = {
+ ...state,
+ requested: false,
+ holder: "human",
+ request: { ...state.request, status: "taken" },
+ };
+ }
+ if (path.endsWith("/control/release") && state.request)
+ state = {
+ ...state,
+ holder: "bot",
+ request: { ...state.request, status: "completed" },
+ };
+ return Response.json(state);
+ },
+ { preconnect: () => undefined },
+ );
+ return {
+ calls,
+ fail: () => {
+ fail = true;
+ },
+ recover: () => {
+ fail = false;
+ },
+ delayTake: () => {
+ holdTake = true;
+ },
+ finishTake: () => completeTake?.(),
+ transition: () => {
+ state = { ...state, transitioning: true };
+ },
+ };
+}
+function controls(botId = "control-test") {
+ const view = render(
+ <>
+
+
+ >,
+ );
+ return {
+ ...view,
+ chat: within(view.getByRole("region", { name: "Chat" })),
+ sidebar: within(view.getByRole("region", { name: "Computer sidebar" })),
+ };
+}
+
+test("chat and sidebar always show controls and update together using the exact request", async () => {
+ const backend = server();
+ const view = controls();
+ await waitFor(() =>
+ expect(
+ view.chat
+ .getByRole("button", { name: "Take control" })
+ .hasAttribute("disabled"),
+ ).toBe(false),
+ );
+ fireEvent.click(view.chat.getByRole("button", { name: "Take control" }));
+ expect(
+ await view.sidebar.findByRole("button", { name: "Hand back" }),
+ ).toBeTruthy();
+ expect(view.chat.getByRole("button", { name: "Hand back" })).toBeTruthy();
+ fireEvent.click(view.sidebar.getByRole("button", { name: "Hand back" }));
+ expect(
+ await view.chat.findByRole("button", { name: "Take control" }),
+ ).toBeTruthy();
+ expect(
+ backend.calls
+ .filter(
+ (call) =>
+ call.path.endsWith("/control/take") ||
+ call.path.endsWith("/control/release"),
+ )
+ .map((call) => call.body),
+ ).toEqual([{ requestId: "request-1" }, { requestId: "request-1" }]);
+ expect(
+ backend.calls.filter((call) => call.path.endsWith("/control/request")),
+ ).toHaveLength(1);
+});
+
+test("an in-flight takeover disables both controls and prevents duplicate requests", async () => {
+ const backend = server();
+ backend.delayTake();
+ const view = controls();
+ await waitFor(() =>
+ expect(
+ view.chat
+ .getByRole("button", { name: "Take control" })
+ .hasAttribute("disabled"),
+ ).toBe(false),
+ );
+ fireEvent.click(view.chat.getByRole("button", { name: "Take control" }));
+ await waitFor(() =>
+ expect(
+ view.sidebar
+ .getByRole("button", { name: "Take control" })
+ .hasAttribute("disabled"),
+ ).toBe(true),
+ );
+ fireEvent.click(view.sidebar.getByRole("button", { name: "Take control" }));
+ backend.finishTake();
+ await view.chat.findByRole("button", { name: "Hand back" });
+ expect(
+ backend.calls.filter((call) => call.path.endsWith("/control/take")),
+ ).toHaveLength(1);
+});
+
+test("a failed takeover is visible in both surfaces and can be retried", async () => {
+ const backend = server();
+ backend.fail();
+ const view = controls();
+ await waitFor(() =>
+ expect(
+ view.chat
+ .getByRole("button", { name: "Take control" })
+ .hasAttribute("disabled"),
+ ).toBe(false),
+ );
+ fireEvent.click(view.chat.getByRole("button", { name: "Take control" }));
+ expect(await view.chat.findByRole("alert")).toBeTruthy();
+ expect(view.sidebar.getByRole("alert")).toBeTruthy();
+ backend.recover();
+ fireEvent.click(view.sidebar.getByRole("button", { name: "Take control" }));
+ expect(
+ await view.chat.findByRole("button", { name: "Hand back" }),
+ ).toBeTruthy();
+ expect(view.chat.queryByRole("alert")).toBeNull();
+});
+
+test("active human control is immediately offered as Hand back on mounting either surface", async () => {
+ server("human");
+ const view = controls();
+ expect(
+ await view.chat.findByRole("button", { name: "Hand back" }),
+ ).toBeTruthy();
+ expect(view.sidebar.getByRole("button", { name: "Hand back" })).toBeTruthy();
+});
+
+test("Strict Mode keeps chat and sidebar on one ownership store", async () => {
+ const backend = server();
+ const view = render(
+
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(
+ view
+ .getAllByRole("button", { name: "Take control" })
+ .every((button) => !button.hasAttribute("disabled")),
+ ).toBe(true),
+ );
+ fireEvent.click(view.getAllByRole("button", { name: "Take control" })[0]!);
+ await waitFor(() =>
+ expect(view.getAllByRole("button", { name: "Hand back" })).toHaveLength(2),
+ );
+ expect(
+ backend.calls.filter((call) => call.path.endsWith("/control/request")),
+ ).toHaveLength(1);
+});
+
+test("a takeover stays shared when chat remounts before its response", async () => {
+ const backend = server();
+ backend.delayTake();
+ const first = render( );
+ await waitFor(() =>
+ expect(first.getByRole("button").hasAttribute("disabled")).toBe(false),
+ );
+ fireEvent.click(first.getByRole("button"));
+ await waitFor(() =>
+ expect(
+ backend.calls.some((call) => call.path.endsWith("/control/take")),
+ ).toBe(true),
+ );
+ first.unmount();
+ const second = render( );
+ expect(second.getByRole("button").hasAttribute("disabled")).toBe(true);
+ backend.finishTake();
+ expect(await second.findByRole("button", { name: "Hand back" })).toBeTruthy();
+});
+
+test("the labeled Computer toggle opens and closes without changing ownership", async () => {
+ const backend = server();
+ function Chat() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
+ {open ? Live computer sidebar
: null}
+ >
+ );
+ }
+ const view = render( );
+ await waitFor(() =>
+ expect(
+ view
+ .getByRole("button", { name: "Take control" })
+ .hasAttribute("disabled"),
+ ).toBe(false),
+ );
+ fireEvent.click(view.getByRole("button", { name: "Open Computer" }));
+ expect(view.getByText("Live computer sidebar")).toBeTruthy();
+ fireEvent.click(view.getByRole("button", { name: "Close Computer" }));
+ expect(view.queryByText("Live computer sidebar")).toBeNull();
+ expect(backend.calls.filter((call) => call.body)).toHaveLength(0);
+});
+
+test("an active human prompt opens once and does not fight closing the Computer", async () => {
+ server("human");
+ function Chat() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ setOpen(next)}
+ />
+ {open ? Live computer sidebar
: null}
+ >
+ );
+ }
+ const view = render( );
+ expect(await view.findByText("Live computer sidebar")).toBeTruthy();
+ fireEvent.click(view.getByRole("button", { name: "Close Computer" }));
+ view.rerender( );
+ expect(view.queryByText("Live computer sidebar")).toBeNull();
+ expect(view.getByRole("button", { name: "Hand back" })).toBeTruthy();
+});
+
+test("both controls stay disabled while the current browser action drains", async () => {
+ const backend = server();
+ backend.transition();
+ const view = controls("draining-control");
+ expect(await view.chat.findByRole("status")).toBeTruthy();
+ expect(view.sidebar.getByRole("status")).toBeTruthy();
+ expect(
+ view.chat
+ .getByRole("button", { name: "Take control" })
+ .hasAttribute("disabled"),
+ ).toBe(true);
+ expect(
+ view.sidebar
+ .getByRole("button", { name: "Take control" })
+ .hasAttribute("disabled"),
+ ).toBe(true);
+ fireEvent.click(view.chat.getByRole("button", { name: "Take control" }));
+ expect(backend.calls.filter((call) => call.body)).toHaveLength(0);
+});
From d073d54f00bb3a88a21a7a081691b2165cc5c278 Mon Sep 17 00:00:00 2001
From: David McKay
Date: Sat, 26 Sep 2026 17:07:01 -0700
Subject: [PATCH 2/2] Avoid serializing pending sidebar DOM in route regression
---
app/tests/bot-route-default-agent.test.tsx | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/app/tests/bot-route-default-agent.test.tsx b/app/tests/bot-route-default-agent.test.tsx
index 5e9a175b5..f650dcdd9 100644
--- a/app/tests/bot-route-default-agent.test.tsx
+++ b/app/tests/bot-route-default-agent.test.tsx
@@ -332,9 +332,10 @@ test("/bot opens a live Computer sidebar with ownership controls and preserves t
target: { value: "Keep this conversation" },
});
fireEvent.click(toggle);
- const sidebar = within(
- await view.findByRole("region", { name: "Computer sidebar" }),
- );
+ const sidebarElement = await view.findByRole("region", {
+ name: "Computer sidebar",
+ });
+ const sidebar = within(sidebarElement);
expect(
sidebar
.getByRole("button", { name: "Take control" })
@@ -347,9 +348,9 @@ test("/bot opens a live Computer sidebar with ownership controls and preserves t
).toBeTruthy();
expect(sidebar.getByRole("heading", { name: "Activity" })).toBeTruthy();
fireEvent.click(view.getByRole("button", { name: "Close Computer" }));
- await waitFor(() =>
- expect(view.queryByRole("region", { name: "Computer sidebar" })).toBeNull(),
- );
+ // Poll a boolean: Bun serializes the entire Happy DOM tree when a pending
+ // element is compared with null, starving the navigation this wait observes.
+ await waitFor(() => expect(sidebarElement.isConnected).toBe(false));
expect(view.getByTestId("copilot-chat")).toBe(chat);
expect(chat.dataset.agentId).toBe("general-assistant");
expect(view.getByDisplayValue("Keep this conversation")).toBeTruthy();