env changes
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
91
src/components/shell/assistantContext.test.ts
Normal file
91
src/components/shell/assistantContext.test.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
251
src/components/shell/assistantContext.ts
Normal file
251
src/components/shell/assistantContext.ts
Normal 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 };
|
||||||
|
}
|
||||||
70
src/components/shell/composerState.test.ts
Normal file
70
src/components/shell/composerState.test.ts
Normal 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…');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user