env changes

This commit is contained in:
2026-09-24 13:18:02 +05:30
parent 23da1872f2
commit a13f7caddc
4 changed files with 459 additions and 147 deletions

View File

@@ -8,6 +8,7 @@ import {
type AssistantAnswer, type AssistantAnswer,
} from '@/api/assistant'; } from '@/api/assistant';
import { useAssistantScope } from './assistantScope'; import { useAssistantScope } from './assistantScope';
import { matchAssistantRoute } from './assistantContext';
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react'; import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
import { import {
DEFAULT_WIDTH, DEFAULT_WIDTH,
@@ -17,132 +18,6 @@ import {
useAssistantWidth, useAssistantWidth,
} from './assistantWidth'; } from './assistantWidth';
/**
* 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.
*
* 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,
{
page: string;
title: string;
greeting: string;
reading: string;
prompts: string[];
agent?: string;
}
> = {
'/nearle/stores': {
page: 'Stores',
title: 'Nearle Buddy',
greeting: 'Every tenant on the platform, and which of them have branches sitting idle.',
reading: 'Would cover the tenant directory — branch counts, status and per-tenant performance.',
prompts: [
'Which tenants have no branches?',
'Who onboarded most recently?',
'What needs attention?',
'Summarise platform growth',
],
},
'/nearle/onboard/tenant': {
page: 'Onboard tenant',
title: 'Provisioning a tenant',
greeting: 'Registering the enterprise also creates its primary Administrator account.',
reading: 'Would cover the onboarding form — required fields, category and head-office address.',
prompts: ['What does provisioning create?', 'Which fields are required?', 'What happens next?'],
},
'/nearle/catalogue': {
page: 'Global catalogue',
title: 'Stocking a store',
greeting: 'The catalogue carries a price range, not a price — the store sets the real one.',
reading: 'Would cover the global catalogue and what this tenant has already imported.',
prompts: [
'Which products are already imported?',
'Catalogue or spreadsheet?',
'What does re-importing do?',
'Which columns does the sheet need?',
],
},
/* ── Store Admin ──────────────────────────────────────────────────────── */
'/admin/console': {
agent: 'console',
page: 'Console',
title: 'Across your branches',
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
reading: 'Would cover every branch — revenue by channel, stock health, till status and what is waiting on you.',
prompts: [
'Which branch is underperforming?',
'Any tills not syncing?',
'What needs my approval?',
'Where is stock running out?',
],
},
'/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.',
reading: 'Would cover orders, counter bills and delivery progress across your branches.',
prompts: [
'Why is the cancel rate high?',
'Which orders are stuck?',
'Compare online and counter sales',
'What is out for delivery?',
],
},
'/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?',
'What is low or out of stock?',
'What has run out completely?',
'Which branch is waiting on most stock?',
],
},
'/admin/users': {
page: 'Users & access',
title: 'Who can sign in',
greeting: 'A till account and a console login are two different things — a cashier has no console access at all.',
reading: 'Would cover your back-office directory and the till accounts at each branch.',
prompts: [
'What is the difference between the two?',
'How do I add a cashier?',
'Why can I not delete someone?',
'Who is inactive?',
],
},
'/admin/reports': {
page: 'Reports',
title: 'Revenue, sales and stock',
greeting: 'Fast and slow movers are the two lists that change what you order next.',
reading: 'Would cover revenue by channel and branch, product performance and stock movement.',
prompts: [
'Which products are slow moving?',
'Compare my branches',
'Online versus counter revenue',
'What is my inventory worth?',
],
},
};
/** One question and what came back. `answer` and `error` are exclusive. */ /** One question and what came back. `answer` and `error` are exclusive. */
interface Exchange { interface Exchange {
question: string; question: string;
@@ -172,7 +47,11 @@ function replaceLast(thread: Exchange[], update: (entry: Exchange) => Exchange):
* model can do nothing, but a person on the Inventory page can move to Sales * model can do nothing, but a person on the Inventory page can move to Sales
* and get an answer today. * and get an answer today.
*/ */
function composerHint(available: boolean | null, hasAgent: boolean, isAsking: boolean): string { export function composerHint(
available: boolean | null,
hasAgent: boolean,
isAsking: boolean,
): string {
if (isAsking) return 'Thinking…'; if (isAsking) return 'Thinking…';
if (available === null) return 'Checking…'; if (available === null) return 'Checking…';
if (available === false) return 'Not connected yet'; if (available === false) return 'Not connected yet';
@@ -180,6 +59,33 @@ function composerHint(available: boolean | null, hasAgent: boolean, isAsking: bo
return 'Ask about this page'; return 'Ask about this page';
} }
/**
* The line under the composer, or nothing.
*
* This used to be one hardcoded sentence — "Not connected yet — there is no
* assistant service behind this panel." — written before the panel had an API
* behind it and never removed once it did. It rendered on every page in every
* state, so the panel told everybody Buddy was off, permanently, including on a
* deployment where Buddy was answering questions. It also flatly contradicted
* the placeholder three lines above it, which was the only part telling the
* truth.
*
* The lesson worth keeping: a status message with no condition attached is not
* a status message. Each branch here reads the same two facts the composer
* itself is disabled by, so the two cannot drift apart again.
*/
export function composerNote(available: boolean | null, hasAgent: boolean): string {
// Still asking. A sentence that appears for 200ms and is replaced reads as a
// flicker, so the space stays empty until there is something true to put in it.
if (available === null) return '';
if (available === false) return 'Not connected yet — there is no assistant service behind this panel.';
if (!hasAgent) return 'No assistant for this page yet. Try Console or Sales.';
// Buddy is working. The caveat that matters then is not about connection: it
// is that an answer is a model reading live rows, and the rows are the thing
// to act on.
return 'Buddy reads your live data. Check anything you act on.';
}
/** /**
* A change waiting on the person. * A change waiting on the person.
* *
@@ -419,13 +325,6 @@ function Exchange({
* through to the fallback would then be a compile error rather than simply a * through to the fallback would then be a compile error rather than simply a
* page with no assistant, which is what it is. * 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.',
reading: 'Would cover this page.',
prompts: ['What needs attention?', 'Summarise this page'],
};
/** /**
* Nearle Buddy, as a layout column. * Nearle Buddy, as a layout column.
@@ -454,8 +353,7 @@ export function AssistantPanel({
const { width, setWidth, reset } = useAssistantWidth(); const { width, setWidth, reset } = useAssistantWidth();
const [isDragging, setDragging] = useState(false); const [isDragging, setDragging] = useState(false);
const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry)); const { key, context: routeContext } = matchAssistantRoute(pathname);
const routeContext = (key ? CONTEXT[key] : undefined) ?? FALLBACK;
/** /**
* The heading follows the branch in view. * The heading follows the branch in view.
@@ -839,17 +737,19 @@ export function AssistantPanel({
</button> </button>
</form> </form>
<p {composerNote(available, Boolean(agent)) ? (
style={{ <p
margin: 0, style={{
fontSize: 10, margin: 0,
lineHeight: 1.4, fontSize: 10,
color: 'var(--color-ink-4)', lineHeight: 1.4,
textAlign: 'center', color: 'var(--color-ink-4)',
}} textAlign: 'center',
> }}
Not connected yet — there is no assistant service behind this panel. >
</p> {composerNote(available, Boolean(agent))}
</p>
) : null}
</div> </div>
</aside> </aside>
); );

View File

@@ -0,0 +1,91 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { CONTEXT, matchAssistantRoute } from './assistantContext';
/*
The bug these exist for: the console has THREE workspaces — `/nearle` for
platform staff, `/admin` for a merchant, `/store` for a branch user — and this
map only knew about two. A store user therefore got the fallback on every page,
and the fallback carries no agent, so the composer was dead for that entire role
however the deployment was configured.
Nothing caught it because `/store/console` and `/admin/console` render the same
component and look identical on screen, down to the heading. The only difference
is the pathname, which is precisely the input this map takes.
*/
/** The routes that must be able to answer, per workspace. */
const MUST_HAVE_AN_AGENT = [
'/admin/console',
'/admin/sales',
'/admin/inventory',
'/store/console',
'/store/sales',
];
test('every workspace that can answer, does', () => {
for (const pathname of MUST_HAVE_AN_AGENT) {
const { context } = matchAssistantRoute(pathname);
assert.ok(
context.agent,
`${pathname} has no agent, so its composer is disabled whatever the backend says`,
);
}
});
test('the two console pages answer with the same agent', () => {
// `/admin/console` and `/store/console` are the same page at two scopes. One
// of them carrying an agent and the other not is the exact shape of the bug,
// and it is invisible in the rendered output.
const merchant = matchAssistantRoute('/admin/console').context;
const branch = matchAssistantRoute('/store/console').context;
assert.equal(branch.agent, merchant.agent);
assert.equal(matchAssistantRoute('/store/sales').context.agent, 'orders');
});
test('a page falls through to the fallback rather than to nothing', () => {
const { key, context } = matchAssistantRoute('/somewhere/unmapped');
assert.equal(key, undefined);
assert.equal(context.agent, undefined);
// The fallback must stay agentless. Giving it one would point every unmapped
// page at an assistant that cannot answer what is on it.
assert.ok(context.prompts.length > 0);
});
test('a deeper path still finds its page', () => {
// `/admin/sales/4412` is still the Sales panel. Exact matching would drop the
// agent the moment anybody opened a row.
assert.equal(matchAssistantRoute('/admin/sales/4412').context.agent, 'orders');
assert.equal(matchAssistantRoute('/store/console?branch=3').context.agent, 'console');
});
test('no key shadows another', () => {
// First match wins, in insertion order, so a key that is a prefix of another
// silently swallows it — adding a bare `/store` above `/store/console` would
// take the agent away from the console page and nothing would fail.
const keys = Object.keys(CONTEXT);
for (const key of keys) {
for (const other of keys) {
if (key === other) continue;
assert.ok(
!other.startsWith(key),
`${key} is a prefix of ${other}; whichever is declared first wins and the other is unreachable`,
);
}
}
});
test('every agent named here exists on the backend', () => {
// The agents are YAML in backend_fiesta/services/agents. A name that does not
// match one is refused at the door, and the person sees a failed question
// rather than a disabled composer — worse, because it looks like a fault.
const shipped = new Set(['console', 'orders', 'inventory', 'shopfloor']);
for (const [route, context] of Object.entries(CONTEXT)) {
if (!context.agent) continue;
assert.ok(shipped.has(context.agent), `${route} names an agent that does not exist: ${context.agent}`);
}
});

View File

@@ -0,0 +1,251 @@
/**
* Which page Nearle Buddy is sitting beside, and which agent answers there.
*
* Its own module so it can be tested without rendering the panel. It was
* inline in AssistantPanel.tsx, and a whole workspace went missing from it
* unnoticed — see the store-user block below.
*/
/**
* 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.
*
* 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.
*/
export const CONTEXT: Record<
string,
{
page: string;
title: string;
greeting: string;
reading: string;
prompts: string[];
agent?: string;
}
> = {
'/nearle/stores': {
page: 'Stores',
title: 'Nearle Buddy',
greeting: 'Every tenant on the platform, and which of them have branches sitting idle.',
reading: 'Would cover the tenant directory — branch counts, status and per-tenant performance.',
prompts: [
'Which tenants have no branches?',
'Who onboarded most recently?',
'What needs attention?',
'Summarise platform growth',
],
},
'/nearle/onboard/tenant': {
page: 'Onboard tenant',
title: 'Provisioning a tenant',
greeting: 'Registering the enterprise also creates its primary Administrator account.',
reading: 'Would cover the onboarding form — required fields, category and head-office address.',
prompts: ['What does provisioning create?', 'Which fields are required?', 'What happens next?'],
},
'/nearle/catalogue': {
page: 'Global catalogue',
title: 'Stocking a store',
greeting: 'The catalogue carries a price range, not a price — the store sets the real one.',
reading: 'Would cover the global catalogue and what this tenant has already imported.',
prompts: [
'Which products are already imported?',
'Catalogue or spreadsheet?',
'What does re-importing do?',
'Which columns does the sheet need?',
],
},
/* ── Store Admin ──────────────────────────────────────────────────────── */
'/admin/console': {
agent: 'console',
page: 'Console',
title: 'Across your branches',
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
reading: 'Would cover every branch — revenue by channel, stock health, till status and what is waiting on you.',
prompts: [
'Which branch is underperforming?',
'Any tills not syncing?',
'What needs my approval?',
'Where is stock running out?',
],
},
'/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.',
reading: 'Would cover orders, counter bills and delivery progress across your branches.',
prompts: [
'Why is the cancel rate high?',
'Which orders are stuck?',
'Compare online and counter sales',
'What is out for delivery?',
],
},
'/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?',
'What is low or out of stock?',
'What has run out completely?',
'Which branch is waiting on most stock?',
],
},
'/admin/users': {
page: 'Users & access',
title: 'Who can sign in',
greeting: 'A till account and a console login are two different things — a cashier has no console access at all.',
reading: 'Would cover your back-office directory and the till accounts at each branch.',
prompts: [
'What is the difference between the two?',
'How do I add a cashier?',
'Why can I not delete someone?',
'Who is inactive?',
],
},
'/admin/reports': {
page: 'Reports',
title: 'Revenue, sales and stock',
greeting: 'Fast and slow movers are the two lists that change what you order next.',
reading: 'Would cover revenue by channel and branch, product performance and stock movement.',
prompts: [
'Which products are slow moving?',
'Compare my branches',
'Online versus counter revenue',
'What is my inventory worth?',
],
},
/* ── Store user ───────────────────────────────────────────────────────────
*
* One branch, fixed. The pages are largely the SAME components the merchant
* sees — `/store/console` and `/admin/console` are both `ConsolePage` — so
* the absence of these entries was invisible in the code and total in the
* product: a store-manager got the fallback on every route, and the fallback
* has no agent, so the composer was dead for that whole role no matter what
* the deployment had configured.
*
* It stayed hidden because every test signed in as one of the other two
* roles, and because `/admin/console` and `/store/console` look identical on
* screen down to the heading.
*
* The wording differs from the merchant's on purpose. "Which branch is
* underperforming?" is not a question a branch user can act on, and the
* answers are scoped to their one branch by the session regardless — so the
* chips say what that scope actually is rather than implying a choice.
*/
'/store/console': {
agent: 'console',
page: 'Console',
title: 'Your branch today',
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
reading: 'Would cover this branch — sales by channel, stock health, till status and what is waiting.',
prompts: [
'Any tills not syncing?',
'Where is stock running out?',
'What needs attention?',
'How is today going?',
],
},
'/store/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.',
reading: 'Would cover this branch — orders, counter bills and delivery progress.',
prompts: [
'Which orders are stuck?',
'What is out for delivery?',
'Compare online and counter sales',
'Why is the cancel rate high?',
],
},
'/store/products': {
// No agent, and not an oversight. The `inventory` agent carries
// `approve_stock_request`, and whether a branch user may approve the stock
// their own branch asked for is a question about who is allowed to spend,
// not about which page this is. Until that is settled the page keeps the
// prompts and loses the composer, which is the honest of the two.
page: 'Products',
title: 'Catalogue and stock',
greeting: 'A product in the catalogue does not mean stock on the shelf — that takes an approved request.',
reading: 'Would cover this branch — what is on the shelf and what has been asked for.',
prompts: [
'What is low or out of stock?',
'What has run out completely?',
'How do I request stock?',
'Why is my request still pending?',
],
},
'/store/reports': {
page: 'Reports',
title: 'Revenue, sales and stock',
greeting: 'Fast and slow movers are the two lists that change what you order next.',
reading: 'Would cover this branch — revenue by channel, product performance and stock movement.',
prompts: [
'Which products are slow moving?',
'Online versus counter revenue',
'What sold best this week?',
'What is my stock worth?',
],
},
'/store/staff': {
page: 'Staff',
title: 'Who can sign in',
greeting: 'A till account and a console login are two different things — a cashier has no console access at all.',
reading: 'Would cover the people at this branch and the till accounts they sign in with.',
prompts: [
'What is the difference between the two?',
'How do I add a cashier?',
'Why can I not delete someone?',
'Who is inactive?',
],
},
};
const FALLBACK: (typeof CONTEXT)[string] = {
page: 'Console',
title: 'Good afternoon',
greeting: 'Ask about anything on this page.',
reading: 'Would cover this page.',
prompts: ['What needs attention?', 'Summarise this page'],
};
/** One route's panel copy. */
export type AssistantRouteContext = (typeof CONTEXT)[string];
/**
* Which entry a pathname lands on, and its key.
*
* Prefix matching, so `/admin/sales/4412` is still the Sales panel. The key is
* returned alongside because the panel clears its thread when it changes — two
* different pages must not share a conversation.
*
* First match wins, in insertion order, which is safe only while no key is a
* prefix of another. That is asserted in the tests rather than left as a
* property somebody has to notice.
*/
export function matchAssistantRoute(pathname: string): {
key: string | undefined;
context: AssistantRouteContext;
} {
const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry));
return { key, context: (key ? CONTEXT[key] : undefined) ?? FALLBACK };
}

View File

@@ -0,0 +1,70 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { composerHint, composerNote } from './AssistantPanel';
/*
The panel carried a hardcoded sentence under the composer — "Not connected yet
— there is no assistant service behind this panel." — written before there was
an API behind it and never removed once there was. It had no condition on it, so
it rendered in every state on every page, and it said Buddy was off while the
placeholder three lines above said "Ask about this page".
That cost real debugging time: the panel was read as evidence the backend had no
model, on a deployment whose backend was answering questions.
So both lines are asserted together here. The rule is that they agree.
*/
const CHECKING = null;
const LIVE = true;
const OFF = false;
test('the note and the placeholder never disagree', () => {
// Whenever the note claims Buddy is not connected, the placeholder must say
// the same, and vice versa. Disagreeing is the bug, in either direction.
for (const available of [CHECKING, LIVE, OFF]) {
for (const hasAgent of [true, false]) {
const note = composerNote(available, hasAgent);
const hint = composerHint(available, hasAgent, false);
assert.equal(
note.startsWith('Not connected yet'),
hint === 'Not connected yet',
`note ${JSON.stringify(note)} disagrees with placeholder ${JSON.stringify(hint)}`,
);
}
}
});
test('a working assistant does not announce that it is disconnected', () => {
// The regression itself.
const note = composerNote(LIVE, true);
assert.ok(!note.includes('Not connected'), `a live panel still said: ${note}`);
assert.equal(composerHint(LIVE, true, false), 'Ask about this page');
});
test('an unconfigured deployment says so', () => {
assert.match(composerNote(OFF, true), /Not connected yet/);
assert.match(composerNote(OFF, false), /Not connected yet/);
});
test('a page with no agent says it is the page, not the deployment', () => {
// Two different facts, and only one of them is something the person can do
// anything about: they can move to a page that has an assistant.
const note = composerNote(LIVE, false);
assert.ok(!note.includes('Not connected'));
assert.match(note, /no assistant for this page/i);
});
test('nothing is claimed while the check is still in flight', () => {
// A sentence that appears and is replaced 200ms later reads as a flicker, and
// the one it used to show was the wrong one.
assert.equal(composerNote(CHECKING, true), '');
assert.equal(composerHint(CHECKING, true, false), 'Checking…');
});
test('asking outranks everything in the placeholder', () => {
assert.equal(composerHint(LIVE, true, true), 'Thinking…');
});