Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 138 additions & 95 deletions CHANGELOG.md

Large diffs are not rendered by default.

71 changes: 63 additions & 8 deletions app/src/components/approvals/rules.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { ruleTitle } from "@openbot/shared/tool-name";
import {
IconChevronRight,
IconLock,
IconPlus,
IconShieldCheck,
} from "@tabler/icons-react";
import type { UseMutationResult } from "@tanstack/react-query";
import { type UseMutationResult, useQuery } from "@tanstack/react-query";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Expand Down Expand Up @@ -33,13 +34,18 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { GONE_BOT, useBotNames } from "@/lib/agents/bot-names";
import {
type ApprovalRuleInput,
type ApprovalRuleRow,
BEHAVIOUR_LABELS,
type HostCommandPolicy,
type RuleBehaviour,
} from "@/lib/approvals";
import {
type HostFolderGrant,
hostAccessQueryOptions,
} from "@/lib/computers/host-access";

export const HOST_LABELS: Record<HostCommandPolicy, string> = {
ask: "Ask every time",
Expand Down Expand Up @@ -97,6 +103,38 @@ const TEAM_BEHAVIOUR_LABELS = Object.fromEntries(
]),
) as Record<RuleBehaviour, string>;

/** What kind of action a rule covers, in words. Any other kind is left unsaid. */
const EFFECT_WORDS: Record<string, string> = {
read: "reading",
write: "changes",
destructive: "deleting",
delegate: "handing off work",
};

/** "for Expenses"; nothing until the names arrive, so a Bot is never named by its id. */
function forBot(name: string): string | null {
if (!name) return null;
return name === GONE_BOT ? "for a Bot no longer here" : `for ${name}`;
}

/** A rule saved from a folder's card is on the id OpenBot gave that folder (`host-access/broker.ts`). */
const FOLDER_GRANT = "host-grant-";

/**
* A folder rule's place by the folder's name, as Settings → Computer shows it: "in share". A grant
* ends when OpenBot stops, so a rule on one that is gone says so rather than naming its id.
*/
function folderWords(
grantId: string,
grants: readonly HostFolderGrant[] | undefined,
): string {
if (!grants) return "in a folder";
const grant = grants.find((candidate) => candidate.id === grantId);
return grant && !grant.revoked
? `in ${grant.displayName}`
: "in a folder that's no longer shared";
}

/**
* One saved rule, as a row: what it matches, and what a Bot does about it. A locked rule — a team
* rule seen by a member, or a personal rule the team has switched off — states its behaviour and
Expand All @@ -118,12 +156,29 @@ export function RuleRow({
onChange: (behaviour: RuleBehaviour) => void;
}) {
const labels = team ? TEAM_BEHAVIOUR_LABELS : BEHAVIOUR_LABELS;
// The tool is the title; the rest of what the rule matches is the line beneath, never clamped,
// since two rules for one tool differ only there.
// The action in its approval card's words is the title ("Click", "Any action on Gmail"); the rest
// of what the rule matches is the line beneath, never clamped, since two rules for one action
// differ only there. The title already names an app's rule's place.
const { title, place } = ruleTitle(rule);
const botName = useBotNames();
const folderRule = rule.scope.startsWith(FOLDER_GRANT);
const folders = useQuery({
...hostAccessQueryOptions(),
refetchInterval: false,
enabled: folderRule,
});
const match = [
rule.effect !== "*" ? rule.effect : null,
rule.scope !== "*" ? `on ${rule.scope}` : null,
rule.botId !== "*" ? `for ${rule.botId}` : null,
EFFECT_WORDS[rule.effect] ?? null,
rule.scope === "*" || place
? null
: folderRule
? folderWords(rule.scope, folders.data?.grants)
: `on ${rule.scope}`,
rule.botId === "*"
? null
: rule.botId.includes("*")
? `for Bots matching ${rule.botId}`
: forBot(botName(rule.botId)),
]
.filter(Boolean)
.join(" ");
Expand All @@ -133,7 +188,7 @@ export function RuleRow({
{locked ? <IconLock /> : <IconShieldCheck />}
</ItemMedia>
<ItemContent>
<ItemTitle>{rule.toolRef}</ItemTitle>
<ItemTitle>{title}</ItemTitle>
{match ? (
<ItemDescription className="line-clamp-none break-all">
{match}
Expand All @@ -150,7 +205,7 @@ export function RuleRow({
<>
<LabelSelect
disabled={disabled}
label={`Behaviour for ${rule.toolRef}`}
label={`Behaviour for ${title}`}
labels={labels}
onChange={onChange}
value={rule.behaviour}
Expand Down
46 changes: 37 additions & 9 deletions app/src/components/approvals/waiting.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { offersStandingRule } from "@openbot/shared/standing-rule";
import { approvalWords } from "@openbot/shared/tool-name";
import { approvalDetails, approvalWords } from "@openbot/shared/tool-name";
import {
IconDots,
IconHandStop,
Expand Down Expand Up @@ -53,6 +53,7 @@ import {
dismissPersonQuestionMutationOptions,
} from "@/lib/approvals";
import { relativeTime } from "@/lib/relative-time";
import { cn } from "@/lib/utils";
import { queryClient } from "@/query-client";

type WaitingQuestion = ApprovalInboxData["questions"][number];
Expand Down Expand Up @@ -127,6 +128,39 @@ function QuestionRow({
);
}

/**
* What an approval would send, a labelled line each: "Text: hello". Nothing when it sends nothing
* the title has not said already, as a click does: its ref and snapshot are handles for the model.
*/
function ApprovalDetails({
args,
className,
}: {
args: unknown;
className?: string;
}) {
const details = approvalDetails(args);
if (details.length === 0) return null;
return (
<dl
aria-label="What it would send"
className={cn(
"max-h-52 space-y-1 overflow-auto rounded bg-muted p-3 text-xs",
className,
)}
>
{details.map((detail) => (
<div key={detail.label}>
<dt className="font-medium">{detail.label}</dt>
<dd className="whitespace-pre-wrap break-words text-muted-foreground">
{detail.value}
</dd>
</div>
))}
</dl>
);
}

/**
* An action waiting on a decision, decided on the row's own footer line, in from the edge by the
* icon and its gap so the buttons line up with the text, as a task's and an item's do beside it.
Expand Down Expand Up @@ -160,9 +194,7 @@ function RequestRow({
{request.action.policy.reason}
</p>
) : null}
<pre className="mt-1 max-h-52 overflow-auto whitespace-pre-wrap rounded bg-muted p-3 text-xs">
{JSON.stringify(request.action.args, null, 2)}
</pre>
<ApprovalDetails args={request.action.args} className="mt-1" />
</ItemContent>
{request.action.policy?.behaviour === "hand_off" ? (
<ItemFooter className="flex-wrap justify-start pl-11">
Expand Down Expand Up @@ -520,11 +552,7 @@ function RequestBoardRow({ request }: { request: WaitingRequest }) {
</span>
) : null,
]}
more={
<pre className="max-h-52 overflow-auto whitespace-pre-wrap rounded bg-muted p-3 text-xs">
{JSON.stringify(request.action.args, null, 2)}
</pre>
}
more={<ApprovalDetails args={request.action.args} />}
open
title={action}
waiting
Expand Down
139 changes: 139 additions & 0 deletions app/tests/approval-card-internals.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render } from "@testing-library/react";
import type { ReactNode } from "react";
import { WaitingForYou } from "@/components/approvals/waiting";
import { BotNeedsYou } from "@/components/needs-you/board";
import { settleReactWork } from "./settle-react-work";

/*
* FOUND IN THE 1.0 RC1 MAC PASS: a pending click's "Needs you" card read "Click / en.wikipedia.org"
* over a raw block, `{ "ref": "f3e43", "snapshotId": 4 }`. Those are the handles the model cites for
* an element on a page snapshot, and nobody deciding can read them. The card names the link from the
* label the server resolved, and shows only what the action would send.
*/

beforeAll(() => GlobalRegistrator.register());
afterEach(cleanup);
afterAll(async () => {
await settleReactWork();
GlobalRegistrator.unregister();
});

const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});

/** A pending computer action as `GET /api/approvals` returns it (`publicApproval`). */
const request = (
id: string,
toolRef: string,
args: Record<string, unknown>,
element: { ref: string; role: string; name: string },
) => ({
id,
status: "pending",
createdAt: "2026-11-08T10:00:00Z",
action: {
botId: "researcher",
toolRef,
effect: "write",
scope: "en.wikipedia.org",
threadId: `thread-${id}`,
toolCallId: `call-${id}`,
args,
target: {
pageUrl: "https://en.wikipedia.org/wiki/Main_Page",
element: { ...element, type: "" },
snapshotId: 4,
},
},
});

function serving() {
global.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/approvals")
return Response.json({
enabled: true,
rules: [],
teamRules: [],
questions: [],
requests: [
request(
"click",
"computer_click",
{ ref: "f3e43", snapshotId: 4 },
{
ref: "f3e43",
role: "link",
name: "Python (programming language)",
},
),
request(
"type",
"computer_type",
{ ref: "f7", snapshotId: 4, text: "monty python" },
{ ref: "f7", role: "searchbox", name: "Search Wikipedia" },
),
],
});
if (url === "/api/proactive/suggestions")
return Response.json({ suggestions: [] });
if (url === "/api/tasks") return Response.json({ tasks: [] });
if (url.startsWith("/api/channels"))
return Response.json({ channels: [], nextCursor: null });
return Response.json({
agents: [{ id: "researcher", name: "Researcher" }],
});
}) as unknown as typeof fetch;
}

function draw(node: ReactNode) {
return render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
{node}
</QueryClientProvider>,
);
}

/** Nothing the model cites, and no raw JSON, anywhere a person reads. */
function expectNoInternals(text: string) {
expect(text).not.toContain("snapshotId");
expect(text).not.toContain("f3e43");
expect(text).not.toContain('"ref"');
expect(text).not.toContain("computer_");
expect(text).not.toContain("{");
}

test("on the Needs you board, a click names the link and shows no raw JSON", async () => {
serving();
const view = draw(<BotNeedsYou agentId="researcher" />);
expect(
await view.findByText("Click the link “Python (programming language)”"),
).toBeTruthy();
expect(view.getByText("Fill in “Search Wikipedia”")).toBeTruthy();
// What a typing step would send is still shown whole, by its label.
expect(view.getByText("Text")).toBeTruthy();
expect(view.getByText("monty python")).toBeTruthy();
expect(view.getAllByText("en.wikipedia.org").length).toBeGreaterThan(0);
expectNoInternals(view.container.textContent ?? "");
});

test("on the Bot's page, a click says what the Bot wants to do and shows no raw JSON", async () => {
serving();
const view = draw(<WaitingForYou agentId="researcher" />);
expect(
await view.findByText(
"Researcher wants to click the link “Python (programming language)”",
),
).toBeTruthy();
expect(view.getByText("monty python")).toBeTruthy();
expectNoInternals(view.container.textContent ?? "");
});
Loading
Loading