diff --git a/src/api/assistant.ts b/src/api/assistant.ts
new file mode 100644
index 0000000..a100826
--- /dev/null
+++ b/src/api/assistant.ts
@@ -0,0 +1,66 @@
+/**
+ * Nearle Buddy.
+ *
+ * Two calls: is this switched on, and here is a question. The tenant is not one
+ * of them — the server reads it from the session token, which is the whole
+ * point. A request body that carried a tenant would be a request body somebody
+ * could edit.
+ */
+
+import { api, WEB } from './client';
+
+/** One tool the assistant ran, so an answer can show its working. */
+export interface AssistantStep {
+ tool: string;
+ /** `ok` or `refused`. Refusals are shown, not hidden — an answer that quietly
+ dropped one would look like Buddy chose not to look. */
+ outcome: string;
+ rows?: number;
+ detail?: string;
+ scope?: string;
+}
+
+export interface AssistantAnswer {
+ reply: string;
+ used?: AssistantStep[];
+ model?: string;
+ /** Console routes showing the rows the answer came from. */
+ sources?: string[];
+ /**
+ * True when the loop hit its own limits, or the model's reply was cut off
+ * mid-sentence. The answer is still worth showing — a partial answer beats a
+ * spinner — but it must not be presented as the whole story.
+ */
+ incomplete?: boolean;
+}
+
+/**
+ * Is the assistant switched on for this deployment?
+ *
+ * Asked once when the panel mounts, and the answer decides whether the composer
+ * is enabled. The panel has said "Not connected yet" since it was built; this is
+ * what finally answers that at runtime rather than at build time.
+ *
+ * Never throws. A console that cannot reach this endpoint should show a
+ * disabled field, not an error page — the panel is beside the work, not the
+ * work itself.
+ */
+export async function assistantAvailable(): Promise {
+ try {
+ const status = await api.get<{ available?: boolean }>(`${WEB}/assistant/status`);
+ return status?.available === true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Ask a question.
+ *
+ * `agent` names which assistant answers — the console sends the one matching the
+ * page the panel sits beside. Phase 2 ships one, so an unknown name is refused
+ * rather than silently answered by the wrong agent.
+ */
+export async function askAssistant(agent: string, question: string): Promise {
+ return api.post(`${WEB}/assistant/ask`, { agent, question });
+}
diff --git a/src/components/shell/AssistantPanel.tsx b/src/components/shell/AssistantPanel.tsx
index 2d8e98a..c68f3f1 100644
--- a/src/components/shell/AssistantPanel.tsx
+++ b/src/components/shell/AssistantPanel.tsx
@@ -1,5 +1,7 @@
-import { useRef, useState } from 'react';
+import { 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 { useAssistantScope } from './assistantScope';
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
import {
@@ -10,10 +12,24 @@ import {
useAssistantWidth,
} from './assistantWidth';
-/** Per-route context, so the panel knows which page it is sitting beside. */
+/**
+ * Per-route context, so the panel knows which page it is sitting beside.
+ *
+ * `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.
+ */
const CONTEXT: Record<
string,
- { page: string; title: string; greeting: string; reading: string; prompts: string[] }
+ {
+ page: string;
+ title: string;
+ greeting: string;
+ reading: string;
+ prompts: string[];
+ agent?: string;
+ }
> = {
'/nearle/stores': {
page: 'Stores',
@@ -50,6 +66,7 @@ const CONTEXT: Record<
/* ── Store Admin ──────────────────────────────────────────────────────── */
'/admin/console': {
+ agent: 'orders',
page: 'Console',
title: 'Across your branches',
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
@@ -62,6 +79,7 @@ const CONTEXT: Record<
],
},
'/admin/sales': {
+ agent: 'orders',
page: 'Sales',
title: 'Orders and deliveries',
greeting: 'An app order and a counter bill are both sales, but only one of them has a delivery.',
@@ -111,7 +129,144 @@ const CONTEXT: Record<
},
};
-const FALLBACK = {
+/** One question and what came back. `answer` and `error` are exclusive. */
+interface Exchange {
+ question: string;
+ answer?: AssistantAnswer;
+ error?: string;
+}
+
+/** Replaces the last entry, which is always the one in flight. */
+function replaceLast(thread: Exchange[], update: (entry: Exchange) => Exchange): Exchange[] {
+ if (thread.length === 0) return thread;
+ return thread.map((entry, index) => (index === thread.length - 1 ? update(entry) : entry));
+}
+
+/**
+ * What the composer says when it cannot be used, and why.
+ *
+ * Three different states with three different sentences. "Not connected yet"
+ * for everything would be true and useless: a person whose deployment has no
+ * model can do nothing, but a person on the Inventory page can move to Sales
+ * and get an answer today.
+ */
+function composerHint(available: boolean | null, hasAgent: boolean, isAsking: boolean): string {
+ if (isAsking) return 'Thinking…';
+ if (available === null) return 'Checking…';
+ if (available === false) return 'Not connected yet';
+ if (!hasAgent) return 'No assistant for this page yet';
+ return 'Ask about this page';
+}
+
+/**
+ * One question and its answer.
+ *
+ * The answer shows what it ran, not just what it concluded. Buddy states things
+ * with the confidence of a sentence, and the only honest way to present that is
+ * beside the tools it used and a link to the page holding the same rows — so a
+ * person can disagree with it.
+ */
+function Exchange({
+ entry,
+ isLast,
+ isAsking,
+}: {
+ entry: Exchange;
+ isLast: boolean;
+ isAsking: boolean;
+}) {
+ const waiting = isLast && isAsking && !entry.answer && !entry.error;
+
+ return (
+
+
+ {entry.question}
+
+
+ {waiting ? (
+
Looking…
+ ) : null}
+
+ {entry.error ? (
+
+ {entry.error}
+
+ ) : null}
+
+ {entry.answer ? (
+
+
+ {entry.answer.reply}
+
+
+ {/* Said plainly, not as a subtle grey hint. A partial answer that
+ looks complete is the failure this flag exists to prevent. */}
+ {entry.answer.incomplete ? (
+
+ This answer is partial — Buddy ran out of room before finishing.
+
+ ) : null}
+
+ {entry.answer.used?.length ? (
+
+ {entry.answer.used
+ .map((step) =>
+ step.outcome === 'ok'
+ ? `${step.tool}${typeof step.rows === 'number' ? ` · ${step.rows}` : ''}`
+ : `${step.tool} · refused`,
+ )
+ .join(' ')}
+
+ ) : null}
+
+ {entry.answer.sources?.length ? (
+
+ {entry.answer.sources.map((source) => (
+
+ See the rows
+
+ ))}
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+/**
+ * Typed against `CONTEXT`'s own shape so the two cannot drift.
+ *
+ * Without this the fallback has no `agent` key at all, and `routeContext` —
+ * which is one or the other — loses the field entirely. A page that fell
+ * through to the fallback would then be a compile error rather than simply a
+ * page with no assistant, which is what it is.
+ */
+const FALLBACK: (typeof CONTEXT)[string] = {
page: 'Console',
title: 'Good afternoon',
greeting: 'Ask about anything on this page.',
@@ -159,6 +314,68 @@ export function AssistantPanel({
const scopeLabel = useAssistantScope();
const context = scopeLabel ? { ...routeContext, title: scopeLabel } : routeContext;
+ /*
+ * Whether Buddy can answer here at all, and why not when it cannot.
+ *
+ * Two separate reasons, kept apart because they are two different facts and
+ * the person can act on one of them. `available === false` means this
+ * deployment has no model configured — nothing to be done from the browser.
+ * `agent === undefined` means this page has no assistant yet, which is about
+ * the page and not the deployment.
+ *
+ * `null` is "not asked yet": the composer stays disabled during the check, so
+ * it is never briefly live against a server that turns out to have no model.
+ */
+ const [available, setAvailable] = useState(null);
+ const [thread, setThread] = useState([]);
+ const [isAsking, setAsking] = useState(false);
+ const agent = routeContext.agent;
+ const canAsk = available === true && Boolean(agent) && !isAsking;
+
+ useEffect(() => {
+ let live = true;
+ void assistantAvailable().then((ok) => {
+ if (live) setAvailable(ok);
+ });
+ // Cancelled on unmount so a slow answer cannot set state on a closed panel.
+ return () => {
+ live = false;
+ };
+ }, []);
+
+ /*
+ * The thread is per-page and deliberately not persisted.
+ *
+ * An answer about Sales sitting above the Inventory page is worse than no
+ * answer: it reads as being about what is on screen. Clearing on navigation
+ * costs a person their history, which is the smaller loss.
+ */
+ useEffect(() => {
+ setThread([]);
+ }, [key]);
+
+ const send = async (text: string) => {
+ const question = text.trim();
+ if (!question || !agent || !canAsk) return;
+
+ setDraft('');
+ setAsking(true);
+ setThread((current) => [...current, { question }]);
+
+ try {
+ const answer = await askAssistant(agent, question);
+ setThread((current) => replaceLast(current, (entry) => ({ ...entry, answer })));
+ } catch (error) {
+ // Shown in the thread rather than as a toast: the question is still on
+ // screen, and the failure belongs next to it.
+ setThread((current) =>
+ replaceLast(current, (entry) => ({ ...entry, error: errorMessage(error) })),
+ );
+ } finally {
+ setAsking(false);
+ }
+ };
+
/**
* Three widths, in priority order: stacked (the phone layout owns it),
* expanded (the one-click half-screen), then whatever the operator dragged
@@ -296,25 +513,53 @@ export function AssistantPanel({
+ {/* The conversation. Above the spacer, so a short thread sits under the
+ greeting and a long one pushes the chips and composer down rather
+ than scrolling away from them. */}
+ {thread.length > 0 ? (
+
+ {thread.map((entry, index) => (
+
+ ))}
+
+ ) : null}
+
{/* Chips wrap, never scroll — a half-visible button at a scroller's edge
is a bug no amount of fade masking fixes. */}
{context.prompts.map((prompt) => (
- setDraft(prompt)} />
+ (canAsk ? void send(prompt) : setDraft(prompt))}
+ />
))}
- {/* Disabled, not merely inert.
-
+ {/* Live, at last — but only when something can actually answer.
+
This composer used to accept text and enable a brand-purple send
- button the moment you typed — then swallow the submit. There is no
- assistant endpoint anywhere in `src/api`. A disabled field says the
- same thing honestly and costs nobody a message they thought they
- sent. */}
+ button the moment you typed, then swallow the submit, because no
+ assistant endpoint existed. It was disabled rather than left to lie.
+ It is enabled here exactly when a model is configured AND this page
+ has an agent, and the placeholder says which of the two is missing
+ otherwise. The rule it keeps is the old one: never accept a message
+ nothing will read. */}