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
1 change: 1 addition & 0 deletions apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
},
"dependencies": {
"@corbits/agent-directory": "workspace:*",
"@corbits/approvals": "workspace:*",
"@corbits/chat": "workspace:*",
"@corbits/commands": "workspace:*",
"@corbits/folded-runs": "workspace:*",
Expand Down
15 changes: 15 additions & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
} from "@intx/hub-sessions";
import { getLogger, setup } from "@intx/log";
import { hexEncode } from "@intx/types";
import { createNeedsYouRoutes } from "@corbits/approvals";
import { createEchoRoutes } from "@workbench/echo";
import { createGitWorkflowPusher } from "@workbench/hub-client";
import { createOnboardingRoutes } from "@workbench/onboarding";
Expand Down Expand Up @@ -213,6 +214,20 @@ export async function createHub(config: HubConfig) {
// runs with c.get("tenant") / c.get("principal") resolved.
app.route(`${TENANT_PREFIX}/echo`, createEchoRoutes());

// The "needs you" list: the same `approval:*`/"resolve" grant Interchange's
// own approve/reject routes require, layered with the agent/bench names
// this tenant's approvals don't carry on their own. Approving and
// rejecting still go straight to Interchange's native routes below --
// this route only ever reads.
app.route(
`${TENANT_PREFIX}/approvals/needs-you`,
createNeedsYouRoutes({
db,
grantStore: createGrantStore(db),
conditionRegistry: { time_window: timeWindowEvaluator },
}),
);

// Chat's own grant store/condition registry, built the same way
// `createApp` builds its default when none is supplied (see
// `@intx/hub-api`'s `mountHubRoutes`): a db-backed grant store and
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,41 @@ export const PrincipalsSchema = paginatedSchema(PrincipalSummary);
export const RunsSchema = paginatedSchema(WorkflowRunSummary);
export const TenantApprovalsSchema = paginatedSchema(ApprovalResponse);

// `@corbits/approvals`'s "needs you" read: the same pending approvals as
// `TenantApprovalsSchema`, but with the agent and bench names already
// resolved server-side, so nothing here ever needs a raw id to render.
export const NeedsYouSchema = type({
items: type({
id: "string",
agentName: "string",
benchName: "string",
headline: "string",
arguments: "object",
status: '"pending"',
createdAt: "string.date.iso",
}).array(),
});

export type Profile = typeof UserProfile.infer;
export type Principal = typeof PrincipalSummary.infer;
export type WorkflowRun = typeof WorkflowRunSummary.infer;
export type Approval = typeof ApprovalResponse.infer;
export type NeedsYou = typeof NeedsYouSchema.infer;
export type NeedsYouItem = NeedsYou["items"][number];

/**
* How many things need this bench's attention right now — the count the
* second column's "Approvals" row badges. `null` while unknown (no bench
* selected yet, or the read hasn't resolved), so a caller never mistakes
* "still loading" for "zero pending."
*/
export function useNeedsYouCount(tenantId: string | null): number | null {
const query = useAPIQuery(
tenantId === null ? "" : `/api/tenants/${tenantId}/approvals/needs-you`,
NeedsYouSchema,
);
return query.kind === "ready" ? query.data.items.length : null;
}

/**
* The envelope paginatedSchema validates, stated structurally: the generic
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/optional-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
// helpers build the omitting form once instead of forking every JSX call
// site into two branches.

import type { ReactNode } from "react";

export function countProp(count: number | undefined): { count?: number } {
return count === undefined ? {} : { count };
}
Expand All @@ -13,3 +15,11 @@ export function subtitleProp(subtitle: string | undefined): {
} {
return subtitle === undefined ? {} : { subtitle };
}

export function metaProp(meta: ReactNode | undefined): { meta?: ReactNode } {
return meta === undefined ? {} : { meta };
}

export function badgeProp(badge: ReactNode | undefined): { badge?: ReactNode } {
return badge === undefined ? {} : { badge };
}
55 changes: 27 additions & 28 deletions apps/web/src/pages/approvals-page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
// Approvals, fanned out per-bench: `GET /api/me/approvals` is a hub stub
// that always returns `[]` (see the tenancy inventory's gap list), so this
// reads the current bench's pending approvals from the real, tenant-scoped
// `GET /api/tenants/:tenantId/approvals` instead. Approve only offers scope
// "once" — the hub rejects "always" with a 400 because a standing grant
// needs the tool identity the suspend path doesn't capture yet — and reject
// collects an optional message before resolving.
// Approvals, fanned out per-bench: the list reads
// `GET /api/tenants/:tenantId/approvals/needs-you` (`@corbits/approvals`),
// which resolves each pending approval's agent and bench names so nothing
// here ever renders a raw agent address or run id. Approve/reject still
// post straight to Interchange's own
// `/api/tenants/:tenantId/approvals/:id/{approve,reject}` routes, keyed by
// the same `id` the needs-you list carries — resolving stays exactly-once
// and grant-scoped there, this page only ever composes the display. Approve
// only offers scope "once" — the hub rejects "always" with a 400 because a
// standing grant needs the tool identity the suspend path doesn't capture
// yet — and reject collects an optional message before resolving.

import {
ApprovalCard,
Expand All @@ -28,19 +32,14 @@ import { useState } from "react";
import {
approveApproval,
rejectApproval,
TenantApprovalsSchema,
NeedsYouSchema,
useAPIQuery,
} from "../api";
import { countProp } from "../optional-props";
import type { APIQuery, Approval } from "../api";
import type { APIQuery, NeedsYouItem } from "../api";
import { useBench } from "../bench-context";
import { QueryView } from "../query-view";

function approvalHeadline(approval: Approval): string {
const toolName = approval.toolDefinition["name"];
return typeof toolName === "string" ? toolName : "Run a tool";
}

export function ApprovalsPage({
approvals,
onApprove,
Expand All @@ -49,14 +48,14 @@ export function ApprovalsPage({
rejectingId = null,
actionError = null,
}: {
readonly approvals: APIQuery<Approval[]>;
readonly onApprove: (approval: Approval) => void;
readonly onReject: (approval: Approval, message?: string) => void;
readonly approvals: APIQuery<NeedsYouItem[]>;
readonly onApprove: (approval: NeedsYouItem) => void;
readonly onReject: (approval: NeedsYouItem, message?: string) => void;
readonly approvingId?: string | null;
readonly rejectingId?: string | null;
readonly actionError?: string | null;
}) {
const [rejectTarget, setRejectTarget] = useState<Approval | null>(null);
const [rejectTarget, setRejectTarget] = useState<NeedsYouItem | null>(null);

return (
<>
Expand Down Expand Up @@ -84,9 +83,9 @@ export function ApprovalsPage({
{rows.map((approval) => {
const request: ApprovalRequest = {
id: approval.id,
headline: approvalHeadline(approval),
requestedBy: approval.agentAddress,
details: Object.entries(approval.toolArguments).map(
headline: approval.headline,
requestedBy: `${approval.agentName} in ${approval.benchName}`,
details: Object.entries(approval.arguments).map(
([label, value]) => ({
label,
value:
Expand Down Expand Up @@ -141,7 +140,7 @@ function RejectDialog({
onClose,
onConfirm,
}: {
readonly approval: Approval | null;
readonly approval: NeedsYouItem | null;
readonly onClose: () => void;
readonly onConfirm: (message?: string) => void;
}) {
Expand Down Expand Up @@ -203,22 +202,22 @@ export function ApprovalsRoute() {
const approvals = useAPIQuery(
selectedTenantId === null
? ""
: `/api/tenants/${selectedTenantId}/approvals`,
TenantApprovalsSchema,
: `/api/tenants/${selectedTenantId}/approvals/needs-you`,
NeedsYouSchema,
reloadKey,
);
const rows: APIQuery<Approval[]> =
const rows: APIQuery<NeedsYouItem[]> =
selectedTenantId === null
? { kind: "loading" }
: approvals.kind === "ready"
? { kind: "ready", data: approvals.data.data }
? { kind: "ready", data: approvals.data.items }
: approvals;

function reload() {
setReloadKey((value) => value + 1);
}

function handleApprove(approval: Approval) {
function handleApprove(approval: NeedsYouItem) {
if (selectedTenantId === null) return;
setActionError(null);
setApprovingId(approval.id);
Expand All @@ -228,7 +227,7 @@ export function ApprovalsRoute() {
.finally(() => setApprovingId(null));
}

function handleReject(approval: Approval, message?: string) {
function handleReject(approval: NeedsYouItem, message?: string) {
if (selectedTenantId === null) return;
setActionError(null);
setRejectingId(approval.id);
Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/shell/rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@
// The footer still composes the bench switcher and identity docks the rail
// needs below the page icons.

import { SidebarRail } from "@corbits/react-ui";
import { Badge, SidebarRail } from "@corbits/react-ui";

import { useNeedsYouCount } from "../api";
import { useBench } from "../bench-context";
import { badgeProp } from "../optional-props";
import { NAV_ROUTES, matchesRoute, type AppRoute } from "../routes";
import type { SessionUser } from "../session";
import { BenchDock, RailIdentity } from "./docks";

const APPROVALS_PATH = "/approvals";

export function Rail({
path,
onNavigate,
Expand All @@ -31,6 +36,16 @@ export function Rail({
const activeRoute = NAV_ROUTES.find((route) =>
matchesRoute(route.path, path),
);
const { selectedTenantId } = useBench();
// Which running workflows are parked waiting on this bench's approval —
// Interchange's own "needs you" state, read through `@corbits/approvals`.
// After the page list moved onto the rail, the count badges the Approvals
// icon itself (`SidebarRailItem.badge`), not a contextual-panel row.
const needsYouCount = useNeedsYouCount(selectedTenantId);
const needsYouBadge =
needsYouCount !== null && needsYouCount > 0 ? (
<Badge tone="accent">{needsYouCount}</Badge>
) : undefined;

return (
<SidebarRail
Expand All @@ -41,6 +56,7 @@ export function Rail({
id: route.path,
label: route.label,
icon: route.icon,
...badgeProp(route.path === APPROVALS_PATH ? needsYouBadge : undefined),
}))}
onSelect={onNavigate}
footer={
Expand Down
26 changes: 24 additions & 2 deletions apps/web/test/pages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { renderToStaticMarkup } from "react-dom/server";

import type { ArtifactSummary } from "@corbits/artifact-ui";

import type { APIQuery, Approval } from "../src/api";
import type { APIQuery, NeedsYouItem } from "../src/api";
import type {
AgentDefinition,
AgentDirectoryData,
Expand Down Expand Up @@ -62,13 +62,35 @@ describe("empty states", () => {
test("approvals says nothing is waiting", () => {
const markup = renderToStaticMarkup(
<ApprovalsPage
approvals={ready<Approval[]>([])}
approvals={ready<NeedsYouItem[]>([])}
onApprove={() => undefined}
onReject={() => undefined}
/>,
);
expect(markup).toContain("No approvals waiting");
});

test("approvals renders resolved agent/bench names, never a raw agent address or run id", () => {
const item: NeedsYouItem = {
id: "apr_1",
agentName: "Outreach Composer",
benchName: "Growth Team Bench",
headline: "send_email",
arguments: { to: "customer@example.com" },
status: "pending",
createdAt: "2026-01-01T00:00:00.000Z",
};
const markup = renderToStaticMarkup(
<ApprovalsPage
approvals={ready<NeedsYouItem[]>([item])}
onApprove={() => undefined}
onReject={() => undefined}
/>,
);
expect(markup).toContain("Outreach Composer");
expect(markup).toContain("Growth Team Bench");
expect(markup).not.toContain("apr_1");
});
});

describe("signed-out state", () => {
Expand Down
Loading
Loading