Skip to content
6 changes: 0 additions & 6 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1733,7 +1733,6 @@ tr.insights-row-clickable:hover {
justify-content: flex-end;
gap: 0.4rem;
}
.workbench-info-approval-list,
.workbench-info-schedule-list {
display: flex;
flex-direction: column;
Expand All @@ -1747,11 +1746,6 @@ tr.insights-row-clickable:hover {
color: var(--muted-foreground);
text-decoration: line-through;
}
.workbench-info-approval-row {
display: flex;
flex-direction: column;
gap: 0.4rem;
}

/* A message's attachments: a package gets a Deploy card, anything else a
quiet file list. */
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/approval-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { approveApproval, rejectApproval } from "./api";
import type { Approval } from "./api";
import { getApprovalDetail } from "./pending-approvals";
import { tenantKeys } from "./query-client";
import { markApprovalAnswered } from "./worker-status";

export function createChatApprovalActions(
tenantId: string,
Expand All @@ -35,6 +36,7 @@ export function createChatApprovalActions(
// A 409 (already resolved, run no longer running) is kept distinct from
// a generic error: never something a retry fixes.
async function resolve(
approvalId: string,
call: () => Promise<Approval>,
status: "approved" | "rejected",
forbiddenMessage: string,
Expand All @@ -44,6 +46,7 @@ export function createChatApprovalActions(
// The native route only ever returns 200 with the exact terminal
// status asked for, so `status` here is not a guess.
await call();
markApprovalAnswered(queryClient, tenantId, approvalId);
invalidate();
return { kind: "resolved", status };
} catch (cause) {
Expand Down Expand Up @@ -94,6 +97,7 @@ export function createChatApprovalActions(
},
approve(approvalId) {
return resolve(
approvalId,
() => approveApproval(tenantId, approvalId),
"approved",
CHAT_STRINGS.blockApproveActionForbidden,
Expand All @@ -102,6 +106,7 @@ export function createChatApprovalActions(
},
allowStanding(approvalId) {
return resolve(
approvalId,
() => approveApproval(tenantId, approvalId, "always"),
"approved",
CHAT_STRINGS.blockApproveActionForbidden,
Expand All @@ -110,6 +115,7 @@ export function createChatApprovalActions(
},
reject(approvalId) {
return resolve(
approvalId,
() => rejectApproval(tenantId, approvalId),
"rejected",
CHAT_STRINGS.blockDenyActionForbidden,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
// One pending approval with its approve/deny buttons. Shared by the
// workbench workbench's info column and the chat transcript, so a person can
// answer an agent's ask wherever they are looking when it lands.
// One pending approval in the bench drawer's "Needs you" section: who is
// asking, the exact call it is gating, and the approve/deny buttons that
// unpark the run.

import { Button, formatRelativeTime } from "@corbits/react-ui";
import { toast } from "@corbits/react-ui/ui/toast";
import { useMutation, useQueryClient } from "@tanstack/react-query";

import { ApiQueryError } from "@/lib/api-query";
import { approveApproval, rejectApproval } from "../api";
import { IdentityAvatar } from "../chat/avatar";
import type { PendingApproval } from "../pending-approvals";
import { tenantKeys } from "../query-client";
import { markApprovalAnswered } from "../worker-status";

// The hub answers 409 when the ask was already resolved or the run that
// raised it has stopped (e.g. a hub restart); either way no retry helps.
const STALE_APPROVAL =
"This request can't be answered anymore: it was already resolved or its worker stopped.";

export function ApprovalRow({
item,
Expand All @@ -22,11 +30,16 @@ export function ApprovalRow({
mutationFn: (action: "approve" | "deny") =>
action === "approve" ? approveApproval(tenantId, item.id) : rejectApproval(tenantId, item.id),
onSuccess: () => {
markApprovalAnswered(queryClient, tenantId, item.id);
void queryClient.invalidateQueries({
queryKey: tenantKeys.pendingApprovals(tenantId),
});
},
onError: (cause, action) => {
if (cause instanceof ApiQueryError && cause.status === 409) {
toast(STALE_APPROVAL);
return;
}
toast(
cause instanceof Error
? cause.message
Expand All @@ -37,25 +50,23 @@ export function ApprovalRow({
const pending = resolveMutation.isPending ? resolveMutation.variables : null;

return (
<li className="workbench-info-approval-row">
<div>
<span className="workbench-info-cell-primary">{item.headline}</span>
<br />
{item.toolName !== undefined && (
<>
{/* Arguments are untrusted agent output: rendered as plain text
* nodes only, never as markup. */}
<span className="workbench-info-cell-context">
{item.toolName}
{item.argumentsSummary !== undefined ? `: ${item.argumentsSummary}` : ""}
</span>
<br />
</>
)}
<span className="workbench-info-cell-context">
{item.agentName} · {formatRelativeTime(item.createdAt)}
<div className="drawer-approval">
<div className="drawer-li">
<IdentityAvatar kind="agent" name={item.agentName} principalId={item.agentAddress} />
<span className="drawer-li-t">
<b>{item.headline}</b>
<span>
{item.agentName}
{item.toolName === undefined ? "" : ` · ${item.toolName}`} ·{" "}
{formatRelativeTime(item.createdAt)}
</span>
</span>
</div>
{/* Arguments are untrusted agent output: rendered as a plain text node
* only, never as markup. */}
{item.argumentsSummary !== undefined ? (
<code className="drawer-approval-call">{item.argumentsSummary}</code>
) : null}
<div className="workbench-info-row-actions">
<Button
variant="ghost"
Expand All @@ -75,6 +86,6 @@ export function ApprovalRow({
{pending === "approve" ? "Approving…" : "Approve"}
</Button>
</div>
</li>
</div>
);
}
8 changes: 7 additions & 1 deletion apps/web/src/bench/bench-pill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function BenchPill({
readonly onToggle: () => void;
}) {
const working = status.tone === "working";
const needsYou = status.text === "Needs you";
return (
<button
type="button"
Expand All @@ -39,7 +40,12 @@ export function BenchPill({
<IdentityAvatar kind="agent" name={worker.name} principalId={worker.id} />
)}
<b>{benchName}</b>
{working ? (
{needsYou && worker !== undefined ? (
<>
<span className="pulse" aria-hidden="true" />
<span className="bench-pill-status">{worker.name} needs you</span>
</>
) : working ? (
<>
<span className="pulse" aria-hidden="true" />
<span className="bench-pill-status">
Expand Down
37 changes: 18 additions & 19 deletions apps/web/src/bench/drawer.css
Original file line number Diff line number Diff line change
Expand Up @@ -114,25 +114,6 @@
box-shadow: 0 0 0 2px var(--surface);
border-radius: 8px;
}
.drawer-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
color: var(--ink-2);
background: color-mix(in srgb, var(--action) 14%, transparent);
}
.drawer-pill::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--action);
}
.drawer-sec-head button {
padding: 0;
border: 0;
Expand Down Expand Up @@ -208,3 +189,21 @@
.drawer-list-text > b {
font-weight: 800;
}

/* A pending approval: the asker's row, the call it gates, then its answer. */
.drawer-approval {
display: grid;
gap: 6px;
padding-bottom: 6px;
}
.drawer-approval-call {
display: block;
padding: 6px 8px;
border-radius: var(--r-md);
background: var(--hover);
color: var(--ink-2);
font-family: var(--font-mono, monospace);
font-size: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
17 changes: 2 additions & 15 deletions apps/web/src/bench/information-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { WorkbenchSchedulesPanel } from "../pages/workbench-schedules-panel";
import { usePendingApprovals } from "../pending-approvals";
import { benchLink } from "../shell/page-crumbs";
import type { WorkerStatus } from "../worker-status";
import { ApprovalRow } from "./approval-row";
import { DrawerTabContext } from "./bench-drawer";
import "./description.css";
import { DESCRIPTION_MAX, useBenchDescription } from "./description";
Expand Down Expand Up @@ -257,21 +258,7 @@ export function InformationTab({
{pendingApprovals !== null && pendingApprovals.length > 0 ? (
<div className="drawer-list">
{pendingApprovals.map((item) => (
<div key={item.id} className="drawer-li">
<IdentityAvatar
kind="agent"
name={item.agentName}
principalId={item.agentAddress}
/>
<span className="drawer-li-t">
<b>{item.headline}</b>
<span>
{item.agentName}
{item.toolName === undefined ? "" : ` · ${item.toolName}`}
</span>
</span>
<span className="drawer-pill">Waiting</span>
</div>
<ApprovalRow key={item.id} item={item} tenantId={workbenchTenantId} />
))}
</div>
) : null}
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/bench/thread-approvals.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// The asks this workbench's live worker is parked on, rendered in the thread
// where the decision happens. An ask whose run is gone stays listed in the
// drawer, but never here: it can no longer be answered.

import { useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";

import { ApproveBlockView } from "@/chat/blocks/approve-block";
import { CHAT_STRINGS } from "@/chat/strings";
import { sameAddress } from "@/chat/threads-api";
import { TenantApprovalsSchema, useAPIQuery } from "../api";
import { createChatApprovalActions } from "../approval-actions";
import { pendingApprovalsPath } from "../pending-approvals";

export function ThreadApprovals({
workbenchTenantId,
agentAddresses,
}: {
readonly workbenchTenantId: string;
/** Live run addresses of this workbench's agents. */
readonly agentAddresses: readonly string[];
}) {
const queryClient = useQueryClient();
const actions = useMemo(
() => createChatApprovalActions(workbenchTenantId, queryClient),
[workbenchTenantId, queryClient],
);
const approvals = useAPIQuery(pendingApprovalsPath(workbenchTenantId), TenantApprovalsSchema);
if (approvals.kind !== "ready") return null;

const asks = approvals.data.data.filter(
(row) =>
row.status === "pending" &&
agentAddresses.some((address) => sameAddress(address, row.agentAddress)),
);
return asks.map((row) => (
<ApproveBlockView
key={row.id}
data={{ approvalId: row.id, title: CHAT_STRINGS.blockApproveNeedsYouTitle }}
actions={actions}
/>
));
}
1 change: 1 addition & 0 deletions apps/web/src/chat/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export const CHAT_STRINGS = {
blockUnsupportedTitle: "Unsupported block",
blockUnsupportedBody: (type: string) => `This "${type}" block can't be shown here yet.`,
blockApproveAction: "Approve",
blockApproveNeedsYouTitle: "Needs your approval",
blockDenyAction: "Deny",
blockApproveAllowOnce: "Allow once",
blockApproveAlways: "Always allow for this worker",
Expand Down
18 changes: 12 additions & 6 deletions apps/web/src/pages/workbench-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { PROVIDER_SETTINGS_PATH, ProviderSkipBanner } from "../provider-skip-ban
import { useNavigate } from "../navigation";
import { isClassifiedInferenceFailureText } from "@/chat/inference-failure";
import { AgentRedeployer } from "../agent-redeployer";
import { ThreadApprovals } from "../bench/thread-approvals";
import { useRestartAgent } from "../use-restart-agent";
import { workbenchIdFromPath } from "../workbench-path";

Expand Down Expand Up @@ -228,9 +229,10 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string }
});

// Invalidates rather than patches: the stream carries no thread
// identity. Also invalidates approvals, since a parked ask sends no
// mail but still ticks the stream. An agent reply may have saved an
// artifact too, so the panel and library counts stay in sync.
// identity. Also invalidates approvals, since new mail can follow a
// resolved ask; a parked ask itself ticks nothing (see worker-status.ts).
// An agent reply may have saved an artifact too, so the panel and library
// counts stay in sync.
useEffect(
() =>
subscribeToInbox(workbenchTenantId, () => {
Expand Down Expand Up @@ -270,9 +272,9 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string }
const workerStatus = useWorkerStatus(workbenchTenantId, agents[0]?.id);
const restart = useRestartAgent();
// Only a live agent can be addressed, so only one can be mentioned.
const mentionables = agents
.filter((agent) => agent.address.includes("@"))
.map((agent) => ({ id: agent.id, name: agent.name }));
const liveAgents = agents.filter((agent) => agent.address.includes("@"));
const liveAgentAddresses = liveAgents.map((agent) => agent.address);
const mentionables = liveAgents.map((agent) => ({ id: agent.id, name: agent.name }));
const send = useMutation({
mutationFn: ({
content,
Expand Down Expand Up @@ -394,6 +396,10 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string }
workbenchTenantId={workbenchTenantId}
participants={roster}
/>
<ThreadApprovals
workbenchTenantId={workbenchTenantId}
agentAddresses={liveAgentAddresses}
/>
{workerStatus.tone === "working" ? <WorkingLabel /> : null}
</div>
{send.error === null ? null : (
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/worker-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";

import { turnSettled } from "./worker-status";

const WORKER = "run_live@bench.localhost";
const SENT_AT = Date.parse("2026-10-13T12:00:00Z");

function pendingAsk(agentAddress: string) {
return {
id: `apr_${agentAddress}`,
tenantId: "tnt_bench",
anchorRunId: "run_anchor",
runId: "run_anchor",
agentAddress,
correlationId: "corr",
toolDefinition: { name: "run_shell" },
toolArguments: { command: "echo hi" },
scope: null,
status: "pending" as const,
timeoutAt: null,
resolvedAt: null,
createdAt: "2026-10-13T11:00:00Z",
updatedAt: "2026-10-13T11:00:00Z",
};
}

describe("turnSettled", () => {
test("an ask left pending by a stopped run does not end the live worker's turn", () => {
const orphan = pendingAsk("run_gone@bench.localhost");
expect(turnSettled(SENT_AT, [], [orphan], WORKER)).toBe(false);
});

test("the live worker parking on an ask ends the turn", () => {
expect(turnSettled(SENT_AT, [], [pendingAsk(WORKER.toUpperCase())], WORKER)).toBe(true);
});
});
Loading
Loading