Skip to content

Commit 6f205bb

Browse files
authored
Merge pull request #518 from emoss08/claude/billing-exception-agent-phase-1-l4uyab
feat(client): Agent Control settings page for the billing exception agent
2 parents 33f56f5 + 6f66df4 commit 6f205bb

7 files changed

Lines changed: 212 additions & 0 deletions

File tree

client/apps/web/src/config/navigation.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,13 @@ export const adminLinks: SidebarLink[] = [
962962
resource: Resource.DashControl,
963963
requiredOperation: Operation.Read,
964964
},
965+
{
966+
href: "/admin/agent-control",
967+
title: "Agent Control",
968+
group: "Organization",
969+
resource: Resource.AgentControl,
970+
requiredOperation: Operation.Read,
971+
},
965972
{
966973
href: "/admin/cost-control",
967974
title: "Cost Control",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
AgentControlFieldsFragmentDoc,
3+
AgentControlSettingsDocument,
4+
UpdateAgentControlDocument,
5+
type AgentControlFieldsFragment,
6+
type AgentControlInput,
7+
} from "@trenova/graphql/generated/graphql";
8+
import { getFragmentData } from "@trenova/graphql/generated";
9+
import { requestGraphQL } from "@trenova/shared/lib/graphql";
10+
11+
export type AgentControl = AgentControlFieldsFragment;
12+
13+
export async function fetchAgentControl(): Promise<AgentControl> {
14+
const data = await requestGraphQL({
15+
document: AgentControlSettingsDocument,
16+
operationName: "AgentControlSettings",
17+
});
18+
19+
return getFragmentData(AgentControlFieldsFragmentDoc, data.agentControl);
20+
}
21+
22+
export async function updateAgentControl(input: AgentControlInput): Promise<AgentControl> {
23+
const data = await requestGraphQL({
24+
document: UpdateAgentControlDocument,
25+
operationName: "UpdateAgentControl",
26+
variables: { input },
27+
});
28+
29+
return getFragmentData(AgentControlFieldsFragmentDoc, data.updateAgentControl);
30+
}

client/apps/web/src/router.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,14 @@ const routes: RouteObject[] = [
883883
return { Component: DashControlPage };
884884
},
885885
},
886+
{
887+
path: "agent-control",
888+
loader: createPermissionLoader(Resource.AgentControl, Operation.Read),
889+
async lazy() {
890+
const { AgentControlPage } = await import("@/routes/agent-control/page");
891+
return { Component: AgentControlPage };
892+
},
893+
},
886894
{
887895
path: "distance-controls",
888896
loader: createPermissionLoader(Resource.DistanceControl),
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { NumberField } from "@/components/fields/number-field";
2+
import { SwitchField } from "@/components/fields/switch-field";
3+
import { FormSaveDock } from "@/components/form-save-dock";
4+
import { useApiMutation } from "@/hooks/use-api-mutation";
5+
import { fetchAgentControl, updateAgentControl } from "@/lib/graphql/agent-control";
6+
import { agentControlSchema, type AgentControlFormValues } from "@/types/agent-control";
7+
import { Alert, AlertDescription, AlertTitle } from "@trenova/shared/components/ui/alert";
8+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@trenova/shared/components/ui/card";
9+
import { Form, FormControl, FormGroup } from "@trenova/shared/components/ui/form";
10+
import { zodResolver } from "@hookform/resolvers/zod";
11+
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
12+
import { useCallback } from "react";
13+
import { FormProvider, type Resolver, useForm, useFormContext, useWatch } from "react-hook-form";
14+
import { toast } from "sonner";
15+
16+
const AGENT_CONTROL_QUERY_KEY = ["agent-control"];
17+
18+
export default function AgentControlForm() {
19+
const queryClient = useQueryClient();
20+
const { data } = useSuspenseQuery({
21+
queryKey: AGENT_CONTROL_QUERY_KEY,
22+
queryFn: fetchAgentControl,
23+
});
24+
25+
const defaultValues: AgentControlFormValues = {
26+
billingAgentEnabled: data.billingAgentEnabled,
27+
shadowMode: data.shadowMode,
28+
decisionTimeoutSeconds: data.decisionTimeoutSeconds,
29+
};
30+
31+
const form = useForm<AgentControlFormValues>({
32+
resolver: zodResolver(agentControlSchema) as Resolver<AgentControlFormValues>,
33+
defaultValues,
34+
values: defaultValues,
35+
});
36+
const { handleSubmit, setError, reset } = form;
37+
38+
const mutation = useApiMutation({
39+
mutationFn: (values: AgentControlFormValues) => updateAgentControl(values),
40+
onSuccess: (_, values) => {
41+
toast.success("Agent control updated");
42+
reset(values);
43+
void queryClient.invalidateQueries({ queryKey: AGENT_CONTROL_QUERY_KEY });
44+
},
45+
setFormError: setError,
46+
resourceName: "Agent Control",
47+
});
48+
49+
const onSubmit = useCallback(
50+
(values: AgentControlFormValues) => mutation.mutate(values),
51+
[mutation],
52+
);
53+
54+
return (
55+
<FormProvider {...form}>
56+
<Form onSubmit={handleSubmit(onSubmit)}>
57+
<div className="flex flex-col gap-4 pb-14">
58+
<BillingAgentCard />
59+
<FormSaveDock saveButtonContent="Save Changes" />
60+
</div>
61+
</Form>
62+
</FormProvider>
63+
);
64+
}
65+
66+
function BillingAgentCard() {
67+
const { control } = useFormContext<AgentControlFormValues>();
68+
const shadowMode = useWatch({ control, name: "shadowMode" });
69+
const billingAgentEnabled = useWatch({ control, name: "billingAgentEnabled" });
70+
71+
return (
72+
<Card>
73+
<CardHeader>
74+
<CardTitle>Billing Exception Agent</CardTitle>
75+
<CardDescription>
76+
The billing exception agent inspects blocked billing queue items, diagnoses why they are
77+
held, and proposes resolutions for a human to approve. It never approves or transitions an
78+
item itself.
79+
</CardDescription>
80+
</CardHeader>
81+
<CardContent className="max-w-prose">
82+
<FormGroup cols={1}>
83+
<FormControl>
84+
<SwitchField
85+
control={control}
86+
name="billingAgentEnabled"
87+
label="Enable Billing Exception Agent"
88+
description="Allow the agent to run against this organization's blocked billing queue items."
89+
position="left"
90+
/>
91+
</FormControl>
92+
<FormControl>
93+
<SwitchField
94+
control={control}
95+
name="shadowMode"
96+
label="Shadow Mode"
97+
description="While on, the agent runs and stores its proposals for observation but they are never surfaced or actionable. Turn off only once you trust the agent's suggestions."
98+
position="left"
99+
/>
100+
</FormControl>
101+
{shadowMode && billingAgentEnabled ? (
102+
<Alert variant="warning">
103+
<AlertTitle>Proposals are hidden</AlertTitle>
104+
<AlertDescription>
105+
Shadow mode is on, so runs complete and persist proposals but nothing appears for
106+
review and no decisions are awaited. Turn shadow mode off to surface proposals and
107+
enable human decisions.
108+
</AlertDescription>
109+
</Alert>
110+
) : null}
111+
<FormControl className="max-w-[420px]">
112+
<NumberField
113+
control={control}
114+
name="decisionTimeoutSeconds"
115+
label="Decision Timeout (seconds)"
116+
description="How long a proposal waits for a human decision before its proposals expire and the run is parked. Defaults to 86400 (24 hours)."
117+
rules={{ required: true }}
118+
/>
119+
</FormControl>
120+
</FormGroup>
121+
</CardContent>
122+
</Card>
123+
);
124+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { SuspenseLoader } from "@trenova/shared/components/component-loader";
2+
import { AdminPageLayout } from "@/components/navigation/sidebar-layout";
3+
import { PageHeader } from "@/components/page-header";
4+
import { lazy } from "react";
5+
6+
const AgentControlForm = lazy(() => import("./_components/agent-control-form"));
7+
8+
export function AgentControlPage() {
9+
return (
10+
<AdminPageLayout>
11+
<PageHeader
12+
title="Agent Control"
13+
description="Configure the billing exception agent — enablement, shadow mode, and how long proposals wait for a human decision"
14+
/>
15+
<SuspenseLoader>
16+
<div className="p-4">
17+
<AgentControlForm />
18+
</div>
19+
</SuspenseLoader>
20+
</AdminPageLayout>
21+
);
22+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { z } from "zod";
2+
3+
const HOUR_IN_SECONDS = 3600;
4+
const MIN_DECISION_TIMEOUT = 5 * 60;
5+
const MAX_DECISION_TIMEOUT = 7 * 24 * HOUR_IN_SECONDS;
6+
7+
export const agentControlSchema = z.object({
8+
billingAgentEnabled: z.boolean(),
9+
shadowMode: z.boolean(),
10+
decisionTimeoutSeconds: z
11+
.number({ message: "Decision timeout is required" })
12+
.int("Decision timeout must be a whole number of seconds")
13+
.min(MIN_DECISION_TIMEOUT, "Decision timeout must be at least 5 minutes")
14+
.max(MAX_DECISION_TIMEOUT, "Decision timeout cannot exceed 7 days"),
15+
});
16+
17+
export type AgentControlFormValues = z.infer<typeof agentControlSchema>;

client/packages/shared/src/types/permission.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ export const Resource = {
162162
SettlementControl: "settlement_control",
163163
DashControl: "dash_control",
164164
DriverPortal: "driver_portal",
165+
AgentRun: "agent_run",
166+
AgentProposal: "agent_proposal",
167+
AgentException: "agent_exception",
168+
AgentControl: "agent_control",
165169
} as const;
166170

167171
export type ResourceType = (typeof Resource)[keyof typeof Resource];

0 commit comments

Comments
 (0)