diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 5a506a4cb..4ba5f90cb 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -145,12 +145,15 @@ async function postJSON(path: string, schema: Validator, body: unknown): P return parsed; } -/** Approves a pending approval. Scope is always "once": the hub rejects - * "always" with a 400 (see `vendor/intx/hub-api/src/routes/approvals.ts`), - * so this surface never offers it. */ -export function approveApproval(tenantId: string, approvalId: string): Promise { +/** Approves a pending approval. Scope "always" is the stock standing + * approval: the run's grant for this tool becomes allow. */ +export function approveApproval( + tenantId: string, + approvalId: string, + scope: "once" | "always" = "once", +): Promise { return postJSON(`/api/tenants/${tenantId}/approvals/${approvalId}/approve`, ApprovalResponse, { - scope: "once", + scope, }); } diff --git a/apps/web/src/approval-actions.ts b/apps/web/src/approval-actions.ts index 4fee9e67c..b6b069ef3 100644 --- a/apps/web/src/approval-actions.ts +++ b/apps/web/src/approval-actions.ts @@ -78,6 +78,10 @@ export function createChatApprovalActions( agentName: result.item.agentName, headline: result.item.headline, arguments: result.item.arguments, + ...(result.item.toolName !== undefined ? { toolName: result.item.toolName } : {}), + ...(result.item.argumentsSummary !== undefined + ? { argumentsSummary: result.item.argumentsSummary } + : {}), }, }; case "forbidden": @@ -96,6 +100,14 @@ export function createChatApprovalActions( CHAT_STRINGS.blockApproveActionError, ); }, + allowStanding(approvalId) { + return resolve( + () => approveApproval(tenantId, approvalId, "always"), + "approved", + CHAT_STRINGS.blockApproveActionForbidden, + CHAT_STRINGS.blockApproveActionError, + ); + }, reject(approvalId) { return resolve( () => rejectApproval(tenantId, approvalId), diff --git a/apps/web/src/chat/blocks/approval-actions.ts b/apps/web/src/chat/blocks/approval-actions.ts index 5db723655..535cbc1a9 100644 --- a/apps/web/src/chat/blocks/approval-actions.ts +++ b/apps/web/src/chat/blocks/approval-actions.ts @@ -3,12 +3,6 @@ // Never invented here — mirrors `ApprovalResponse.status`. export type ApprovalLiveStatus = "pending" | "approved" | "rejected" | "timeout" | "expired"; -// Absent means "not offerable here," never "offerable but hidden." -export type StandingConsentOffer = { - readonly verb: string; - readonly resource: string; -}; - // The authoritative "what am I approving" — the block's own title/body is // the agent's framing and never a substitute for it. export type PlatformApprovalDetail = { @@ -22,7 +16,10 @@ export type PlatformApprovalDetail = { // Replaces a risk-level badge, which only repeated the agent's own // framing back at the human deciding against it. readonly consequence?: string; - readonly standingConsent?: StandingConsentOffer; + /** The tool being called, when the approval's snapshot names it. */ + readonly toolName?: string; + /** Compact plain-text rendering of the call's arguments. */ + readonly argumentsSummary?: string; }; // See docs/chat-wire-contract.md for why `forbidden` is distinct from @@ -56,7 +53,6 @@ export type ApprovalActions = { readonly approve: (approvalId: string) => Promise; /** Calls the same native `/reject` route Inbox calls. */ readonly reject: (approvalId: string) => Promise; - // Optional: `hub-api` rejects `scope: "always"` today, so omit until a - // host can wire it to something real. - readonly allowStanding?: (approvalId: string) => Promise; + /** Stock `/approve` with scope "always": the tool stops asking for this run. */ + readonly allowStanding: (approvalId: string) => Promise; }; diff --git a/apps/web/src/chat/blocks/approve-block.tsx b/apps/web/src/chat/blocks/approve-block.tsx index bb7816562..a05d8571f 100644 --- a/apps/web/src/chat/blocks/approve-block.tsx +++ b/apps/web/src/chat/blocks/approve-block.tsx @@ -2,11 +2,12 @@ // authoritative. No `ApprovalActions` port: falls back to pre-round-trip // framing, fixed disabled buttons, no fetch. -import { Button, toast } from "@corbits/react-ui"; +import { Button } from "@corbits/react-ui"; import type { ApproveBlockData } from "../wire/blocks"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useState } from "react"; +import { CorbitAvatar } from "../avatar"; import { CHAT_STRINGS } from "../strings"; import { BlockCard } from "./block-card"; import type { @@ -14,9 +15,7 @@ import type { ApprovalLiveStatus, ApprovalStatusQuery, PlatformApprovalDetail, - StandingConsentOffer, } from "./approval-actions"; -import type { DecisionInFlight } from "./approve-card-state"; import { deriveApproveCardView } from "./approve-card-state"; function statusLabel(status: ApprovalLiveStatus): string { @@ -34,6 +33,23 @@ function statusLabel(status: ApprovalLiveStatus): string { } } +type Choice = "once" | "always" | "ask" | "reject"; + +function resolvedLabel(choice: Choice | null, status: ApprovalLiveStatus): string { + switch (choice) { + case "once": + return CHAT_STRINGS.blockApproveResolvedOnce; + case "always": + return CHAT_STRINGS.blockApproveResolvedAlways; + case "ask": + return CHAT_STRINGS.blockApproveResolvedAskEvery; + case "reject": + return CHAT_STRINGS.blockApproveResolvedDenied; + case null: + return statusLabel(status); + } +} + /** The platform's own account of the request -- always rendered first and * unmissable whenever it's available, so a human never decides against * only the agent's framing. */ @@ -41,10 +57,18 @@ function PlatformDetail({ detail }: { readonly detail: PlatformApprovalDetail }) const args = Object.entries(detail.arguments); return (
-

- {CHAT_STRINGS.blockApprovePlatformRequestedBy(detail.agentName)} -

-

{detail.headline}

+
+ +
+

+ {CHAT_STRINGS.blockApprovePlatformRequestedBy(detail.agentName)} +

+

{detail.headline}

+
+
+ {detail.toolName !== undefined && ( +

{detail.toolName}

+ )} {args.length > 0 && (
{args.map(([label, value]) => ( @@ -60,52 +84,55 @@ function PlatformDetail({ detail }: { readonly detail: PlatformApprovalDetail }) } function ApproveButtons({ - actionLabel, deciding, - onApprove, - onDeny, - standingConsent, - allowingStanding, - onAllowStanding, + onDecide, }: { - readonly actionLabel: string; - readonly deciding: DecisionInFlight; - readonly onApprove: () => void; - readonly onDeny: () => void; - readonly standingConsent: StandingConsentOffer | undefined; - readonly allowingStanding: boolean; - readonly onAllowStanding: (() => void) | null; + readonly deciding: Choice | null; + readonly onDecide: (choice: Choice) => void; }) { const busy = deciding !== null; return ( -
-
- - -
- {standingConsent !== undefined && onAllowStanding !== null && ( - - )} +
+ + + + +
); } @@ -133,23 +160,27 @@ export function ApproveBlockView({ // Never trust a decision response (or a local guess) over the platform's // own state — every outcome, success or failure alike, re-reads the // status above and renders only what comes back. + const [choice, setChoice] = useState(null); const decideMutation = useMutation({ - mutationFn: (kind: "approve" | "reject") => { + mutationFn: (picked: Choice) => { if (actions === undefined) throw new Error("approval actions unavailable"); - const call = kind === "approve" ? actions.approve : actions.reject; - return call(data.approvalId); + switch (picked) { + case "reject": + return actions.reject(data.approvalId); + case "always": + return actions.allowStanding(data.approvalId); + case "once": + case "ask": + return actions.approve(data.approvalId); + } }, - onSuccess: (result, kind) => { + onSuccess: (result, picked) => { if (result.kind === "resolved") { setResolvedElsewhere(false); - toast( - kind === "approve" - ? CHAT_STRINGS.blockApproveStatusApproved - : CHAT_STRINGS.blockApproveStatusRejected, - ); + setChoice(picked); } else if (result.kind === "conflict") { // Someone/something else resolved this first. There is nothing to - // retry — the refreshed terminal status speaks, with a calmer note + // retry -- the refreshed terminal status speaks, with a calmer note // than a bare error. setResolvedElsewhere(true); } else { @@ -166,47 +197,18 @@ export function ApproveBlockView({ }, }); - const allowStandingMutation = useMutation({ - mutationFn: () => { - if (actions?.allowStanding === undefined) { - throw new Error("standing approval unavailable"); - } - return actions.allowStanding(data.approvalId); - }, - onSuccess: (result) => { - if (result.kind === "resolved") { - toast(CHAT_STRINGS.blockApproveStatusApproved); - } else if (result.kind !== "conflict") { - setDecisionError( - result.kind === "forbidden" - ? CHAT_STRINGS.blockApproveActionForbidden - : CHAT_STRINGS.blockApproveActionError, - ); - } - }, - onSettled: () => { - void status.refetch(); - }, - }); - - const deciding: DecisionInFlight = decideMutation.isPending ? decideMutation.variables : null; - const allowingStanding = allowStandingMutation.isPending; + const deciding: Choice | null = decideMutation.isPending ? decideMutation.variables : null; - function decide(kind: "approve" | "reject") { + function decide(picked: Choice) { if (actions === undefined) return; setDecisionError(null); - decideMutation.mutate(kind); - } - - function allowStanding() { - if (actions?.allowStanding === undefined) return; - allowStandingMutation.mutate(); + decideMutation.mutate(picked); } const view = deriveApproveCardView({ wired: actions !== undefined, live, - deciding, + deciding: deciding === null ? null : deciding === "reject" ? "reject" : "approve", decisionError, resolvedElsewhere, }); @@ -216,8 +218,22 @@ export function ApproveBlockView({ ? view.detail : null; + if (view.kind === "resolved") { + return ( +
+

+ {resolvedLabel(choice, view.status)} + {detail?.toolName !== undefined ? ` · ${detail.toolName}` : ""} +

+ {view.resolvedElsewhere && ( +

{CHAT_STRINGS.blockApproveConflictNote}

+ )} +
+ ); + } + return ( - + {detail !== null && } {detail?.consequence !== undefined && (

{detail.consequence}

@@ -261,16 +277,6 @@ export function ApproveBlockView({

{CHAT_STRINGS.blockApproveSpectatorNote}

)} - {view.kind === "resolved" && ( - <> -

- {statusLabel(view.status)} -

- {view.resolvedElsewhere && ( -

{CHAT_STRINGS.blockApproveConflictNote}

- )} - - )} {(view.kind === "actionable" || view.kind === "undetermined") && ( <> {view.kind === "undetermined" && ( @@ -281,15 +287,7 @@ export function ApproveBlockView({ {view.error}

)} - decide("approve")} - onDeny={() => decide("reject")} - standingConsent={detail?.standingConsent} - allowingStanding={allowingStanding} - onAllowStanding={actions?.allowStanding !== undefined ? allowStanding : null} - /> + )}
diff --git a/apps/web/src/chat/blocks/block-card.tsx b/apps/web/src/chat/blocks/block-card.tsx index 8eead9bb1..d1aee6a9f 100644 --- a/apps/web/src/chat/blocks/block-card.tsx +++ b/apps/web/src/chat/blocks/block-card.tsx @@ -6,13 +6,16 @@ import type { ReactNode } from "react"; export function BlockCard({ title, + attention = false, children, }: { readonly title: string; + /** The one attention moment in view: an orange ring around the card. */ + readonly attention?: boolean; readonly children: ReactNode; }) { return ( -
+