From 84f90d8ed4d0b21fb0087e1a2edcd0bd20185e3c Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 14 Oct 2026 09:55:49 -0300
Subject: [PATCH 1/3] Tell people plainly when a Computer stopped mid-handoff
The interrupted-handoff banner pasted the reason written for the assistant ("Request help again
to continue") into a sentence for people, and asked them to take control even when nobody had it
and the Bot could carry on. The control state now says whether a person must check the page
(recoveryRequired), the banner has a sentence for each case, and only the one that needs a person
lights the needs-you dot and opens the Computer. The assistant's reason is unchanged.
---
agent-computer/src/control.ts | 1 +
agent-computer/tests/control.test.ts | 2 ++
.../components/computer/computer-controls.tsx | 2 +-
app/src/components/computer/computer-view.tsx | 16 +++++++++++++---
app/src/components/computer/needs-you.ts | 2 +-
app/tests/bot-route-default-agent.test.tsx | 2 ++
app/tests/computer-control-button.test.tsx | 1 +
app/tests/computer-handoff.test.ts | 1 +
server/tests/computer-handoff-contract.test.ts | 1 +
server/tests/headless-tools.test.ts | 1 +
shared/computer-control.ts | 2 ++
11 files changed, 26 insertions(+), 5 deletions(-)
diff --git a/agent-computer/src/control.ts b/agent-computer/src/control.ts
index 2041b0f30..4733a1112 100644
--- a/agent-computer/src/control.ts
+++ b/agent-computer/src/control.ts
@@ -207,6 +207,7 @@ export function createControl(
(active(live) || data.holder === "human" || data.recoveryRequired) &&
barrier.pending() > 0,
resumeSnapshotRequired: data.resumeSnapshotRequired,
+ recoveryRequired: data.recoveryRequired,
...(secret
? {
secretWanted: secret.label,
diff --git a/agent-computer/tests/control.test.ts b/agent-computer/tests/control.test.ts
index eff8942d6..96ee74ead 100644
--- a/agent-computer/tests/control.test.ts
+++ b/agent-computer/tests/control.test.ts
@@ -121,6 +121,7 @@ describe("request-aware handoff", () => {
status: "interrupted",
interruption: "The browser restarted.",
});
+ expect(control.get().recoveryRequired).toBe(true);
expect(control.humanMayDrive()).toBe(false);
expect(() => control.admitBotAction(true)).toThrow(ControlError);
expect(() => control.release(interrupted.id)).toThrow();
@@ -131,6 +132,7 @@ describe("request-aware handoff", () => {
const unanswered = request();
control.interrupt("The browser restarted.");
expect(control.get(unanswered.id).request?.status).toBe("interrupted");
+ expect(control.get().recoveryRequired).toBe(false);
// The page is as the Bot left it, so a fresh look is all it needs, not a person.
expect(() => control.admitBotAction(true)).toThrow(SnapshotRequiredError);
control.snapshotTaken();
diff --git a/app/src/components/computer/computer-controls.tsx b/app/src/components/computer/computer-controls.tsx
index 60a6cc3e2..a6b98e69d 100644
--- a/app/src/components/computer/computer-controls.tsx
+++ b/app/src/components/computer/computer-controls.tsx
@@ -61,7 +61,7 @@ export function ComputerChatControls({
control &&
(control.requested ||
control.holder === "human" ||
- control.request?.status === "interrupted" ||
+ control.recoveryRequired ||
control.secretWanted !== undefined),
);
const promptKey = needsYou
diff --git a/app/src/components/computer/computer-view.tsx b/app/src/components/computer/computer-view.tsx
index e9dc8f1e2..bd5efa58e 100644
--- a/app/src/components/computer/computer-view.tsx
+++ b/app/src/components/computer/computer-view.tsx
@@ -510,10 +510,20 @@ export function ComputerView({
Finishing the assistant's current action before giving you control…
) : null}
- {!settled && control?.request?.status === "interrupted" ? (
+ {/*
+ The request's own interruption reason is written for the assistant, so the person gets
+ their own sentence, and is only asked to act when the assistant is actually waiting on them.
+ */}
+ {!settled && control?.recoveryRequired ? (
- The browser was interrupted. {control.request.interruption} Open the
- screen and take control again to check it.
+ The Computer stopped while you had control, so the assistant can't
+ use the browser yet. Take control to check the page, then hand it
+ back.
+
+ ) : !settled && control?.request?.status === "interrupted" ? (
+
+ The Computer stopped before anyone took control. The assistant will
+ ask again if it still needs you.
) : null}
{/*
diff --git a/app/src/components/computer/needs-you.ts b/app/src/components/computer/needs-you.ts
index 2f712d9cb..c1e8fe138 100644
--- a/app/src/components/computer/needs-you.ts
+++ b/app/src/components/computer/needs-you.ts
@@ -25,7 +25,7 @@ export function useNeedsYou(botId: string | undefined, when: boolean): boolean {
state &&
(state.requested ||
state.holder === "human" ||
- state.request?.status === "interrupted" ||
+ state.recoveryRequired ||
state.secretWanted !== undefined),
),
);
diff --git a/app/tests/bot-route-default-agent.test.tsx b/app/tests/bot-route-default-agent.test.tsx
index c9bb15ccb..104611508 100644
--- a/app/tests/bot-route-default-agent.test.tsx
+++ b/app/tests/bot-route-default-agent.test.tsx
@@ -311,6 +311,7 @@ test("/bot opens a live Computer sidebar with ownership controls and preserves t
requested: false,
transitioning: false,
resumeSnapshotRequired: false,
+ recoveryRequired: false,
});
return Response.json({ error: "No frame yet" }, { status: 404 });
},
@@ -374,6 +375,7 @@ test("/bot?watch=true restores the live Computer panel on reload", async () => {
requested: false,
transitioning: false,
resumeSnapshotRequired: false,
+ recoveryRequired: false,
}),
{ preconnect: originalFetch.preconnect },
);
diff --git a/app/tests/computer-control-button.test.tsx b/app/tests/computer-control-button.test.tsx
index c4c2c09b4..3c8502cb1 100644
--- a/app/tests/computer-control-button.test.tsx
+++ b/app/tests/computer-control-button.test.tsx
@@ -29,6 +29,7 @@ function server(initial: "bot" | "human" = "bot") {
requested: false,
transitioning: false,
resumeSnapshotRequired: false,
+ recoveryRequired: false,
...(initial === "human"
? {
request: {
diff --git a/app/tests/computer-handoff.test.ts b/app/tests/computer-handoff.test.ts
index be0999347..8bbefc3bb 100644
--- a/app/tests/computer-handoff.test.ts
+++ b/app/tests/computer-handoff.test.ts
@@ -29,6 +29,7 @@ function state(status: HandoffStatus, id = "request-1"): ComputerControlState {
requested: status === "waiting",
transitioning: false,
resumeSnapshotRequired: status === "completed",
+ recoveryRequired: false,
request: {
id,
status,
diff --git a/server/tests/computer-handoff-contract.test.ts b/server/tests/computer-handoff-contract.test.ts
index add4132d6..36837738a 100644
--- a/server/tests/computer-handoff-contract.test.ts
+++ b/server/tests/computer-handoff-contract.test.ts
@@ -16,6 +16,7 @@ const state: ComputerControlState = {
requested: true,
transitioning: false,
resumeSnapshotRequired: false,
+ recoveryRequired: false,
request: {
id: "request-1",
toolCallId: "call-1",
diff --git a/server/tests/headless-tools.test.ts b/server/tests/headless-tools.test.ts
index 78ad27ec9..b15558a0d 100644
--- a/server/tests/headless-tools.test.ts
+++ b/server/tests/headless-tools.test.ts
@@ -344,6 +344,7 @@ function computerHarness(deny: string[] = []) {
transitioning: false,
since: "now",
resumeSnapshotRequired: false,
+ recoveryRequired: false,
request: {
id: "durable-handoff",
status: "waiting",
diff --git a/shared/computer-control.ts b/shared/computer-control.ts
index dc29657af..4b8a8211e 100644
--- a/shared/computer-control.ts
+++ b/shared/computer-control.ts
@@ -41,6 +41,8 @@ export type ComputerControlState = {
request?: HandoffRequest;
transitioning: boolean;
resumeSnapshotRequired: boolean;
+ /** A person had the page when the computer stopped, so they must check it before the Bot acts. */
+ recoveryRequired: boolean;
secretWanted?: string;
secretRef?: string;
secretSnapshotId?: number;
From 6a45c673f27555a2f64043c77fd968805675b014 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 14 Oct 2026 09:55:49 -0300
Subject: [PATCH 2/3] Start a new conversation with no Bot picked
/channel/new filled the To: field with the default Bot when the URL named none, so a person had
to notice a recipient they did not choose and remove it. Only ?agent= names one now; otherwise
the field starts empty, open and focused.
---
app/src/routes/_authed/_app/channel/new.tsx | 8 +++-----
app/tests/channel-new-error-state.test.tsx | 14 ++++++++++++++
2 files changed, 17 insertions(+), 5 deletions(-)
diff --git a/app/src/routes/_authed/_app/channel/new.tsx b/app/src/routes/_authed/_app/channel/new.tsx
index 4193fee5e..c09181985 100644
--- a/app/src/routes/_authed/_app/channel/new.tsx
+++ b/app/src/routes/_authed/_app/channel/new.tsx
@@ -23,7 +23,6 @@ import {
ComboboxList,
useComboboxAnchor,
} from "@/components/ui/combobox";
-import { defaultAgentProfile } from "@/lib/agents/default-agent";
import {
type AgentProfile,
agentListQueryOptions,
@@ -76,10 +75,9 @@ function RouteComponent() {
enabled: Boolean(agent) && profiles !== undefined && !listed,
retry: false,
});
- const chosen =
- listed ??
- (fetched?.id === agent ? fetched : undefined) ??
- (agent ? undefined : defaultAgentProfile(profiles));
+ // Only the URL names a recipient up front. Without one the To: field starts empty and open: a
+ // Bot picked on the person's behalf is one they have to notice and remove.
+ const chosen = listed ?? (fetched?.id === agent ? fetched : undefined);
const needsUrlAgentDetail =
Boolean(agent) && profiles !== undefined && !listed;
const waitingForUrlAgent =
diff --git a/app/tests/channel-new-error-state.test.tsx b/app/tests/channel-new-error-state.test.tsx
index ac3bdc9d0..226f5e722 100644
--- a/app/tests/channel-new-error-state.test.tsx
+++ b/app/tests/channel-new-error-state.test.tsx
@@ -188,6 +188,20 @@ test("/channel/new keeps a successful empty roster as an empty picker", async ()
await expectMessageComposerDisabled(view, true);
});
+test("/channel/new without a URL recipient leaves the To: field empty", async () => {
+ const view = renderChannelNew(
+ queryClientWithAgents([
+ agent({ id: "picked-harness", name: "Picked Harness" }),
+ GENERAL_ASSISTANT,
+ ]),
+ );
+
+ await expectMessageComposerDisabled(view, true);
+ expect(
+ view.queryByRole("button", { hidden: true, name: /^Remove / }),
+ ).toBeNull();
+});
+
test("/channel/new keeps a successful visible recipient enabled", async () => {
const view = renderChannelNew(
queryClientWithAgents([GENERAL_ASSISTANT]),
From 080a2647cac7f0daf6d4c9eca693ba1111c7a751 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 14 Oct 2026 09:55:49 -0300
Subject: [PATCH 3/3] Skip an empty transcript when tabbing to the composer
The message scroller's viewport is always a tab stop so the keyboard can scroll it, which put an
empty conversation between the To: field and the composer. It is a tab stop only while there is
something to scroll.
---
app/src/components/ui/message-scroller.tsx | 5 +++
app/tests/message-scroller-tab-stop.test.tsx | 35 ++++++++++++++++++++
2 files changed, 40 insertions(+)
create mode 100644 app/tests/message-scroller-tab-stop.test.tsx
diff --git a/app/src/components/ui/message-scroller.tsx b/app/src/components/ui/message-scroller.tsx
index 7b39d0474..ffca59f03 100644
--- a/app/src/components/ui/message-scroller.tsx
+++ b/app/src/components/ui/message-scroller.tsx
@@ -34,11 +34,16 @@ function MessageScroller({
function MessageScrollerViewport({
className,
+ tabIndex,
...props
}: React.ComponentProps) {
+ // The primitive is always a tab stop so the keyboard can scroll it. One with nothing to scroll,
+ // like a conversation that has not started, is a stop between the To: field and the composer.
+ const scrollable = useMessageScrollerScrollable();
return (
GlobalRegistrator.register());
+afterEach(() => cleanup());
+afterAll(() => GlobalRegistrator.unregister());
+
+function renderViewport(tabIndex?: number) {
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+test("a transcript with nothing to scroll is not a tab stop", () => {
+ const view = renderViewport();
+ expect(view.getByRole("region", { name: "Messages" }).tabIndex).toBe(-1);
+});
+
+test("a caller's own tabIndex still wins", () => {
+ const view = renderViewport(0);
+ expect(view.getByRole("region", { name: "Messages" }).tabIndex).toBe(0);
+});