From a13f7caddc6bf4c312a0c4279db743d4cd402444 Mon Sep 17 00:00:00 2001 From: abhishek Date: Thu, 24 Sep 2026 13:18:02 +0530 Subject: [PATCH] env changes --- src/components/shell/AssistantPanel.tsx | 194 ++++---------- src/components/shell/assistantContext.test.ts | 91 +++++++ src/components/shell/assistantContext.ts | 251 ++++++++++++++++++ src/components/shell/composerState.test.ts | 70 +++++ 4 files changed, 459 insertions(+), 147 deletions(-) create mode 100644 src/components/shell/assistantContext.test.ts create mode 100644 src/components/shell/assistantContext.ts create mode 100644 src/components/shell/composerState.test.ts diff --git a/src/components/shell/AssistantPanel.tsx b/src/components/shell/AssistantPanel.tsx index 29cbe16..c0a53cb 100644 --- a/src/components/shell/AssistantPanel.tsx +++ b/src/components/shell/AssistantPanel.tsx @@ -8,6 +8,7 @@ import { type AssistantAnswer, } from '@/api/assistant'; import { useAssistantScope } from './assistantScope'; +import { matchAssistantRoute } from './assistantContext'; import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react'; import { DEFAULT_WIDTH, @@ -17,132 +18,6 @@ import { useAssistantWidth, } 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. */ interface Exchange { 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 * 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 (available === null) return 'Checking…'; 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'; } +/** + * 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. * @@ -419,13 +325,6 @@ function Exchange({ * 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.', - reading: 'Would cover this page.', - prompts: ['What needs attention?', 'Summarise this page'], -}; /** * Nearle Buddy, as a layout column. @@ -454,8 +353,7 @@ export function AssistantPanel({ const { width, setWidth, reset } = useAssistantWidth(); const [isDragging, setDragging] = useState(false); - const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry)); - const routeContext = (key ? CONTEXT[key] : undefined) ?? FALLBACK; + const { key, context: routeContext } = matchAssistantRoute(pathname); /** * The heading follows the branch in view. @@ -839,17 +737,19 @@ export function AssistantPanel({ -

- Not connected yet — there is no assistant service behind this panel. -

+ {composerNote(available, Boolean(agent)) ? ( +

+ {composerNote(available, Boolean(agent))} +

+ ) : null} ); diff --git a/src/components/shell/assistantContext.test.ts b/src/components/shell/assistantContext.test.ts new file mode 100644 index 0000000..17321f6 --- /dev/null +++ b/src/components/shell/assistantContext.test.ts @@ -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}`); + } +}); diff --git a/src/components/shell/assistantContext.ts b/src/components/shell/assistantContext.ts new file mode 100644 index 0000000..dfe1f63 --- /dev/null +++ b/src/components/shell/assistantContext.ts @@ -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 }; +} diff --git a/src/components/shell/composerState.test.ts b/src/components/shell/composerState.test.ts new file mode 100644 index 0000000..b1c9f56 --- /dev/null +++ b/src/components/shell/composerState.test.ts @@ -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…'); +});