Wire the Buddy composer to the assistant

The panel has been complete except for the one part that answers. Its
composer was deliberately disabled — it used to accept text, light up the
send button, and swallow the submit, because no assistant endpoint
existed. This connects it to the one that does.

It is enabled only when a model is configured AND the page has an agent,
checked at runtime via /assistant/status rather than assumed at build
time. The placeholder says which of the two is missing:

  "Not connected yet"              no model on this deployment
  "No assistant for this page yet" no agent for this route
  "Ask about this page"            live

Two different facts, two different sentences. A person on Inventory can
move to Sales and get an answer today; a person whose deployment has no
model can do nothing from the browser. Flattening both into "not
connected" would be true and useless.

The old rule is kept: never accept a message nothing will read.

An answer shows its working — the tools it ran, their row counts, and a
link to the page holding the same rows. Buddy states things with the
confidence of a sentence, and the only honest way to present that is
beside the evidence, so a person can disagree with it. Refused calls are
shown too: an answer that quietly dropped one would look like Buddy chose
not to look.

A partial answer says so in amber, not as a grey hint. An answer cut off
mid-sentence reads as a complete one otherwise, which is the failure the
flag exists to prevent.

The thread clears on navigation. An answer about Sales sitting above the
Inventory page reads as being about what is on screen; losing the history
is the smaller cost.

Phase 2 ships one agent, covering Console and Sales. The rest arrive as
backend config, not as changes here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 13:14:18 +05:30
parent 34c68034de
commit 8537b09f33
2 changed files with 340 additions and 19 deletions

66
src/api/assistant.ts Normal file
View File

@@ -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<boolean> {
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<AssistantAnswer> {
return api.post<AssistantAnswer>(`${WEB}/assistant/ask`, { agent, question });
}

View File

@@ -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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<p
style={{
margin: 0,
alignSelf: 'flex-end',
maxWidth: '85%',
padding: '7px 11px',
borderRadius: '14px 14px 4px 14px',
background: 'var(--color-brand-tint, rgba(102,37,130,.08))',
color: 'var(--color-ink-1)',
fontSize: 13,
lineHeight: 1.55,
}}
>
{entry.question}
</p>
{waiting ? (
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--color-ink-3)' }}>Looking…</p>
) : null}
{entry.error ? (
<p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: 'var(--color-error, #d64545)' }}>
{entry.error}
</p>
) : null}
{entry.answer ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<p
style={{
margin: 0,
fontSize: 13,
lineHeight: 1.6,
color: 'var(--color-ink-1)',
whiteSpace: 'pre-wrap',
}}
>
{entry.answer.reply}
</p>
{/* 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 ? (
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5, color: 'var(--color-warning, #b7860b)' }}>
This answer is partial — Buddy ran out of room before finishing.
</p>
) : null}
{entry.answer.used?.length ? (
<p style={{ margin: 0, fontSize: 11, lineHeight: 1.5, color: 'var(--color-ink-4)' }}>
{entry.answer.used
.map((step) =>
step.outcome === 'ok'
? `${step.tool}${typeof step.rows === 'number' ? ` · ${step.rows}` : ''}`
: `${step.tool} · refused`,
)
.join(' ')}
</p>
) : null}
{entry.answer.sources?.length ? (
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5 }}>
{entry.answer.sources.map((source) => (
<a
key={source}
href={source}
style={{ color: 'var(--color-brand)', textDecoration: 'none', marginRight: 10 }}
>
See the rows
</a>
))}
</p>
) : null}
</div>
) : null}
</div>
);
}
/**
* 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<boolean | null>(null);
const [thread, setThread] = useState<Exchange[]>([]);
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({
</p>
</div>
{/* 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 ? (
<div
role="log"
aria-label="Conversation with Nearle Buddy"
aria-busy={isAsking}
style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 16 }}
>
{thread.map((entry, index) => (
<Exchange key={index} entry={entry} isLast={index === thread.length - 1} isAsking={isAsking} />
))}
</div>
) : null}
<div style={{ flex: 1 }} />
{/* Chips wrap, never scroll — a half-visible button at a scroller's edge
is a bug no amount of fade masking fixes. */}
<div role="group" aria-label="Suggested prompts" style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{context.prompts.map((prompt) => (
<Chip key={prompt} label={prompt} onSelect={() => setDraft(prompt)} />
<Chip
key={prompt}
label={prompt}
/* A chip sends when Buddy can answer, and only fills the box when it
cannot — so a chip on a page with no assistant still shows what
could be asked rather than doing nothing on click. */
onSelect={() => (canAsk ? void send(prompt) : setDraft(prompt))}
/>
))}
</div>
{/* 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. */}
<form
onSubmit={(event) => event.preventDefault()}
onSubmit={(event) => {
event.preventDefault();
void send(draft);
}}
style={{
display: 'flex',
alignItems: 'flex-end',
@@ -341,9 +586,18 @@ export function AssistantPanel({
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled
placeholder="Not connected yet"
aria-label="Ask Nearle Buddy — not connected yet"
onKeyDown={(event) => {
// Enter sends, shift+Enter writes a second line. The composer is
// one line tall most of the time, so requiring the button would
// make every question a two-action job.
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void send(draft);
}
}}
disabled={!canAsk}
placeholder={composerHint(available, Boolean(agent), isAsking)}
aria-label={`Ask Nearle Buddy — ${composerHint(available, Boolean(agent), isAsking)}`}
style={{
display: 'block',
width: '100%',
@@ -362,18 +616,19 @@ export function AssistantPanel({
<button
type="submit"
aria-label="Send message"
disabled
disabled={!canAsk || draft.trim() === ''}
style={{
width: 28,
height: 28,
borderRadius: 999,
border: 0,
flex: 'none',
background: 'var(--color-surface-sunken)',
color: 'var(--color-ink-4)',
background:
canAsk && draft.trim() !== '' ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
color: canAsk && draft.trim() !== '' ? '#fff' : 'var(--color-ink-4)',
display: 'grid',
placeItems: 'center',
cursor: 'default',
cursor: canAsk && draft.trim() !== '' ? 'pointer' : 'default',
transition: 'background .2s',
}}
>