import { ASSISTANT_CONTEXTS } from './contexts'; /** * Where the Owliver dashboard panel appears. * * The assistant occupies a permanent column in the page layout, so enabling it * on a page is a layout decision, not a feature flag — which is exactly why the * decision lives in one table rather than being scattered across pages. * * Matching is exact, not prefix-based: `/candidates` must not leak an assistant * onto a future `/candidates/:id`, and `/positions/:id` must not inherit one * from `/positions`. */ const PLACEMENT = { admin: { '/admin': 'admin.controlCenter', '/admin/positions': 'admin.positions', /* The authoring form. Owliver belongs here for the same reason it belongs on Positions: the questions asked while specifying a role — what do these weights score, what do comparable roles ask for — are answerable from the page and from what this workspace has already posted. */ '/admin/positions/new': 'admin.createPosition', '/admin/candidates': 'admin.candidatesList', '/admin/candidates-analysis': 'admin.candidates', '/admin/analytics': 'admin.analytics', '/admin/activity': 'admin.activity', '/admin/university': 'admin.forge', '/admin/talent-pool': 'admin.talentPool', '/admin/hired': 'admin.hiredHistory', '/admin/profile': 'admin.profile', /* Agent management. Configuring an agent is a workspace task, not an operational page — see `admin.agentConfigure` for what that means for what Owliver may read here. `agents/new` is listed exactly so it is never read as an agent whose id is "new". */ '/admin/workspace/agents/new': 'admin.agentConfigure', /** * Settings and the rest of the workspace. * * These pages have no agent written for them, and for a while that was read * as "no Owliver here" — the panel did not mount at all, so a product that * answered questions on every Admin page before specialists existed * answered on eight of them afterwards. * * The absence of a specialist is not the absence of an assistant. Each of * these carries the same panel, resolved by this same table, answering from * the general agent. What they do *not* carry is operational data: no skill * declares these surfaces, so a workforce question asked here is declined * and pointed at the page that holds the records rather than answered from * a configuration screen. */ '/admin/settings': 'admin.settings', '/admin/workspace': 'admin.workspace', '/admin/workspace/agents': 'admin.workspaceAgents', '/admin/workspace/skills': 'admin.workspaceSkills', '/admin/workspace/skill-development': 'admin.skillDevelopment', /* The skill editor, listed exactly for the same reason `agents/new` is: so `skills/new` is never read as a skill whose id is "new". This is also the entry the page key is derived from — `pageKeyForRoute` reads the surface table, and this route is the one the `workspace-skill-configure` surface declares. The Owliver editor's addresses reach the same context through the pattern table below; listing them here too would overwrite that key with a path tail. */ '/admin/workspace/skills/new': 'admin.skillConfigure', }, }; /** * Pages that must never carry the assistant, listed explicitly so the intent is * documented rather than implied by omission. * * Admin: login only. Every other surface — including Profile and Settings, * where the questions are about the account rather than the workforce, * and the workspace pages, where they are about the configuration — * carries the panel. * Employer: every page. The contextual panel is an Admin capability. * Talent: every page. The talent portal has the standalone Owliver product, * with its own voice experience, branding and workflow. */ export const EXCLUDED_ROUTES = [ // Every Employer and Talent route: the contextual panel is Admin-only. '/', '/overview', '/positions', '/positions/new', '/positions/:id', '/candidates', /* The Employer aliases only — the Admin `/admin/positions/new` carries the panel, and is listed in the placement table above. */ '/hired', '/talent-pool', '/analytics', '/profile', '/apply', '/university', '/university/:id', '/me', '/identity', '/employee', '/owliver', '/design-system', // Sign-in has no page data to reason about. '/admin/login', // The full candidate profile is one person's record, not a page-level question. '/admin/candidates/:id', ]; /** * The Admin route → context table, exported so the skill registry can derive * page keys from it. One table, so a route added here is addressable by a skill * without a second list to keep in step. */ export const PLACEMENT_ROUTES = PLACEMENT.admin; /** * Routes whose address carries a record id. * * The table above is matched exactly, which is deliberate and stays that way: * `/candidates` must not leak an assistant onto `/candidates/:id`. But an agent * is configured at `/admin/workspace/agents/`, and no exact table can list * an address that contains an id nobody has created yet. * * So a second, much smaller table is consulted **only after the exact lookup * misses**. Every one of the eight operational pages is an exact match and * never reaches this code, so their placement is unchanged by construction * rather than by care. * * `exclude` keeps a sibling literal route — `agents/new` is the same screen and * is listed exactly — from being read as an id. */ const PLACEMENT_PATTERNS = { admin: [ { /* Agent configuration: one path segment after `agents/`, and not a nested route beneath it. */ test: (pathname) => /^\/admin\/workspace\/agents\/[^/]+$/.test(pathname), contextId: 'admin.agentConfigure', }, { /* The Owliver skill editor — `skills/owliver/new` and `skills/owliver/`. Two segments, so it is matched before the one-segment pattern below and can never be read as a skill whose id is "owliver". */ test: (pathname) => /^\/admin\/workspace\/skills\/owliver\/[^/]+$/.test(pathname), contextId: 'admin.skillConfigure', }, { /* The Board skill editor: one segment after `skills/`, and no deeper. `owliver` is excluded because it is a prefix rather than a skill, and the product has no page at that address. */ test: (pathname) => /^\/admin\/workspace\/skills\/(?!owliver$)[^/]+$/.test(pathname), contextId: 'admin.skillConfigure', }, ], }; /** * The dynamic routes, as literal examples. * * Exported so the registry and the checks can reason about a pattern without * re-implementing it. These are addresses the pattern genuinely matches. */ export const PLACEMENT_PATTERN_ROUTES = { '/admin/workspace/agents/:id': 'admin.agentConfigure', '/admin/workspace/skills/:id': 'admin.skillConfigure', '/admin/workspace/skills/owliver/new': 'admin.skillConfigure', '/admin/workspace/skills/owliver/:id': 'admin.skillConfigure', }; /** Returns the context for a role and path, or `null` to render no assistant. */ export function resolveAssistantContext(role, pathname) { /* Exact first, always. The eight operational pages resolve here and never reach the patterns below. */ const id = PLACEMENT[role]?.[pathname]; if (id) return ASSISTANT_CONTEXTS[id] ?? null; const pattern = (PLACEMENT_PATTERNS[role] || []).find((p) => p.test(pathname)); return pattern ? ASSISTANT_CONTEXTS[pattern.contextId] ?? null : null; } /** Every enabled role/route pair — used by the placement verification. */ export function enabledRoutes() { return Object.entries(PLACEMENT).flatMap(([role, routes]) => Object.entries(routes).map(([path, contextId]) => ({ role, path, contextId })) ); }