From f3fe53d2ac6802ea8e7df4899a7eba3fe39265a4 Mon Sep 17 00:00:00 2001
From: abhishek
Date: Wed, 23 Sep 2026 17:25:09 +0530
Subject: [PATCH] agent
---
src/api/assistant.ts | 35 ++++
src/components/shell/AssistantPanel.tsx | 219 +++++++++++++++++++++++-
2 files changed, 246 insertions(+), 8 deletions(-)
diff --git a/src/api/assistant.ts b/src/api/assistant.ts
index a100826..980ba50 100644
--- a/src/api/assistant.ts
+++ b/src/api/assistant.ts
@@ -20,6 +20,26 @@ export interface AssistantStep {
scope?: string;
}
+/** One line on an approval card: what is about to change, in checkable detail. */
+export interface ProposalDetail {
+ label: string;
+ value: string;
+}
+
+/**
+ * A change Buddy has resolved and is waiting on a person for.
+ *
+ * Nothing has happened when this arrives. The `card` is signed by the server
+ * and opaque here — the console sends it back unchanged, and anything the
+ * browser altered stops it verifying.
+ */
+export interface Proposal {
+ summary: string;
+ details?: ProposalDetail[];
+ warning?: string;
+ card: string;
+}
+
export interface AssistantAnswer {
reply: string;
used?: AssistantStep[];
@@ -32,6 +52,8 @@ export interface AssistantAnswer {
* spinner — but it must not be presented as the whole story.
*/
incomplete?: boolean;
+ /** Set when a change is resolved and waiting. One card, never a list. */
+ awaiting?: Proposal;
}
/**
@@ -64,3 +86,16 @@ export async function assistantAvailable(): Promise {
export async function askAssistant(agent: string, question: string): Promise {
return api.post(`${WEB}/assistant/ask`, { agent, question });
}
+
+/**
+ * Perform a change the person has agreed to.
+ *
+ * A separate call with no question in it, because it is a different act: the
+ * card names the action and the session names the person, and no model is
+ * consulted. The server re-checks both against the live database before writing
+ * — so this can legitimately fail with "somebody already approved that", which
+ * is an answer rather than an error.
+ */
+export async function approveAssistant(agent: string, card: string): Promise {
+ return api.post(`${WEB}/assistant/approve`, { agent, card });
+}
diff --git a/src/components/shell/AssistantPanel.tsx b/src/components/shell/AssistantPanel.tsx
index c68f3f1..29cbe16 100644
--- a/src/components/shell/AssistantPanel.tsx
+++ b/src/components/shell/AssistantPanel.tsx
@@ -1,7 +1,12 @@
-import { useEffect, useRef, useState } from 'react';
+import { Fragment, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { errorMessage } from '@/api/client';
-import { askAssistant, assistantAvailable, type AssistantAnswer } from '@/api/assistant';
+import {
+ approveAssistant,
+ askAssistant,
+ assistantAvailable,
+ type AssistantAnswer,
+} from '@/api/assistant';
import { useAssistantScope } from './assistantScope';
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
import {
@@ -17,8 +22,16 @@ import {
*
* `agent` names which assistant answers here, and its absence is meaningful: a
* route without one has no assistant yet, and the composer says so rather than
- * accepting a question nothing can answer. Phase 2 ships one agent covering the
- * two order pages; the rest arrive as backend config, not as changes here.
+ * accepting a question nothing can answer.
+ *
+ * A page is only given an agent when that agent can answer every chip on it. A
+ * chip that comes back "I cannot look that up" is worse than no chip — it reads
+ * as the assistant being broken rather than as a feature not built yet. Reports
+ * has no agent for exactly that reason: two of its four questions have no tool
+ * behind them.
+ *
+ * The agents themselves are YAML on the backend, so a new one is a file there
+ * and one line here.
*/
const CONTEXT: Record<
string,
@@ -66,7 +79,7 @@ const CONTEXT: Record<
/* ── Store Admin ──────────────────────────────────────────────────────── */
'/admin/console': {
- agent: 'orders',
+ agent: 'console',
page: 'Console',
title: 'Across your branches',
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
@@ -92,15 +105,16 @@ const CONTEXT: Record<
],
},
'/admin/inventory': {
+ agent: 'inventory',
page: 'Inventory',
title: 'Catalogue and stock',
greeting: 'A product in the store catalogue does not mean stock on the shelf — that takes an approved request.',
reading: 'Would cover your catalogue, per-branch stock levels and the stock requests waiting on you.',
prompts: [
'What is waiting for approval?',
- 'Which products are unpublished?',
'What is low or out of stock?',
- 'How do I upload a product sheet?',
+ 'What has run out completely?',
+ 'Which branch is waiting on most stock?',
],
},
'/admin/users': {
@@ -134,6 +148,14 @@ interface Exchange {
question: string;
answer?: AssistantAnswer;
error?: string;
+ /**
+ * What became of a change Buddy proposed.
+ *
+ * `undefined` means the card is still on screen waiting. Once decided the
+ * card is replaced by its outcome and cannot be pressed again — a card that
+ * stayed live after approval is a second write waiting to happen.
+ */
+ decision?: { state: 'approving' | 'done' | 'dismissed' | 'failed'; message?: string };
}
/** Replaces the last entry, which is always the one in flight. */
@@ -158,6 +180,127 @@ function composerHint(available: boolean | null, hasAgent: boolean, isAsking: bo
return 'Ask about this page';
}
+/**
+ * A change waiting on the person.
+ *
+ * Framed and set apart from the reply on purpose. The sentence above it was
+ * written by a model; this was resolved by the server against the database, and
+ * the two must not read as one thing. What is shown here — the ids, the names,
+ * the quantity — is what the button actually agrees to.
+ *
+ * Once decided the buttons are gone, replaced by what happened. A card that
+ * stayed pressable after approval is a second write waiting for a double-click.
+ */
+function ApprovalCard({
+ proposal,
+ decision,
+ onDecide,
+}: {
+ proposal: NonNullable;
+ decision: Exchange['decision'];
+ onDecide: (approve: boolean) => void;
+}) {
+ const busy = decision?.state === 'approving';
+
+ const outcomeColour =
+ decision?.state === 'failed'
+ ? 'var(--color-error, #d64545)'
+ : decision?.state === 'done'
+ ? 'var(--color-success, #1f9d55)'
+ : 'var(--color-ink-3)';
+
+ return (
+
+
+ {proposal.summary}
+
+
+ {proposal.details?.length ? (
+
+ {proposal.details.map((detail) => (
+
+
{detail.label}
+
+ {detail.value}
+
+
+ ))}
+
+ ) : null}
+
+ {proposal.warning ? (
+
+ {proposal.warning}
+
+ ) : null}
+
+ {decision === undefined ? (
+
+
+ {/* "Not now", not "Reject". Nothing is refused and nothing is
+ recorded — the card is simply left alone, and it expires unused. */}
+
+
+ ) : (
+
+ {busy
+ ? 'Making the change…'
+ : decision.state === 'dismissed'
+ ? 'Left alone. Nothing was changed.'
+ : (decision.message ?? (decision.state === 'done' ? 'Done.' : 'That did not go through.'))}
+
{entry.answer.sources.map((source) => (
@@ -354,6 +507,50 @@ export function AssistantPanel({
setThread([]);
}, [key]);
+ /*
+ * Approving is its own call, with no question in it.
+ *
+ * Indexed rather than acting on the last exchange: a person can scroll up and
+ * approve an earlier card, and nothing about a card ties it to being the most
+ * recent thing said.
+ */
+ const decide = async (index: number, approve: boolean) => {
+ const entry = thread[index];
+ const card = entry?.answer?.awaiting?.card;
+ if (!card || !agent || entry.decision) return;
+
+ if (!approve) {
+ // Dismissing is purely local: the server was never told, because there is
+ // nothing to undo. The card simply expires unused.
+ setThread((current) =>
+ current.map((e, i) => (i === index ? { ...e, decision: { state: 'dismissed' as const } } : e)),
+ );
+ return;
+ }
+
+ setThread((current) =>
+ current.map((e, i) => (i === index ? { ...e, decision: { state: 'approving' as const } } : e)),
+ );
+
+ try {
+ const done = await approveAssistant(agent, card);
+ setThread((current) =>
+ current.map((e, i) =>
+ i === index ? { ...e, decision: { state: 'done' as const, message: done.reply } } : e,
+ ),
+ );
+ } catch (error) {
+ // "Somebody already approved that" arrives here, and it is an answer
+ // rather than a fault — shown on the card, which stays decided so the
+ // button cannot be pressed again into the same refusal.
+ setThread((current) =>
+ current.map((e, i) =>
+ i === index ? { ...e, decision: { state: 'failed' as const, message: errorMessage(error) } } : e,
+ ),
+ );
+ }
+ };
+
const send = async (text: string) => {
const question = text.trim();
if (!question || !agent || !canAsk) return;
@@ -524,7 +721,13 @@ export function AssistantPanel({
style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 16 }}
>
{thread.map((entry, index) => (
-
+ void decide(index, approve)}
+ />
))}
) : null}