Compare commits
42 Commits
fix/owlive
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| da64001b56 | |||
| 8ac4e1ee88 | |||
| 1ef0c6fc8a | |||
| e676d259b2 | |||
| 34505d7cf6 | |||
| 878a23532a | |||
| 3ddacf269a | |||
| 2f9b3baa56 | |||
| e8207038dd | |||
| 21133a6064 | |||
| 78f1b44c2e | |||
| 34acd63a80 | |||
| 284a7e7671 | |||
| a0f3f77900 | |||
| b99dc7c576 | |||
| 7a95d95151 | |||
| 3f835eee93 | |||
| dde4ba62c6 | |||
| 446df7b37b | |||
| 55ddaa134a | |||
| fc8d7eec52 | |||
| e73929f47e | |||
| eaa677f815 | |||
| ecf5e75d76 | |||
| fcdaa32f4d | |||
| be49184006 | |||
| 3d5f54bd5b | |||
| e7e1e9873f | |||
| 543da9d4e7 | |||
| d440036211 | |||
| 2b8f5746bd | |||
| 3e654c2bf7 | |||
| 1133eca16a | |||
| 02a2ab05ef | |||
| dca184289e | |||
| 64140c7add | |||
| f96f128839 | |||
| d1425f974c | |||
| 1775395256 | |||
| f522b6508e | |||
| 6249e00a3a | |||
| e02a0c23d4 |
209
MIGRATION_BASELINE.md
Normal file
209
MIGRATION_BASELINE.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# TypeScript migration — baseline
|
||||||
|
|
||||||
|
The state of this repository **immediately before** the first source file was
|
||||||
|
renamed, measured rather than assumed. Everything the migration does is checked
|
||||||
|
against these numbers; a figure that moves without a phase claiming it is a
|
||||||
|
regression.
|
||||||
|
|
||||||
|
Measured on 2026-09-11, on `main` with a clean tree, before any change in
|
||||||
|
Milestone 1.
|
||||||
|
|
||||||
|
## The numbers
|
||||||
|
|
||||||
|
| Check | Command | Baseline |
|
||||||
|
|---|---|---|
|
||||||
|
| Types | `npm run typecheck` | **FAILS — 71 errors across 27 files** |
|
||||||
|
| Lint | `npm run lint` | **PASSES**, exit 0 |
|
||||||
|
| Lint (with warnings) | `npx eslint .` | 289 problems — 0 errors, 289 warnings |
|
||||||
|
| Files linted | `npx eslint . --format json` | 302 |
|
||||||
|
| Behaviour | `npm test` | **1641 / 1642 checks passed** |
|
||||||
|
| Build | `npm run build` | succeeds |
|
||||||
|
| Owliver, via the suite | `npm test` section 21 | **all green** — schema, skills, routes, and per-context skills, suggestions, prompts and intent routing |
|
||||||
|
| Owliver, standalone | `node scripts/owliver-baseline.mjs` | **FAILS — pre-existing, see below** |
|
||||||
|
|
||||||
|
### The 71 type errors, by code
|
||||||
|
|
||||||
|
| Code | Count | What it is |
|
||||||
|
|---|---|---|
|
||||||
|
| TS2339 | 38 | Property does not exist on an inferred type |
|
||||||
|
| TS2741 | 9 | Missing required prop in JSX |
|
||||||
|
| TS2353 | 9 | Unknown property in an object literal |
|
||||||
|
| TS2345 | 6 | Argument type mismatch |
|
||||||
|
| TS2554 | 2 | Wrong argument count |
|
||||||
|
| TS2362 / TS2363 | 4 | Arithmetic on a non-number |
|
||||||
|
| TS2739 / TS2559 / TS2349 | 3 | Missing props / no common props / not callable |
|
||||||
|
|
||||||
|
These are **pre-existing**. They were not introduced by the migration and the
|
||||||
|
migration is not obliged to fix them; they are the backlog it has to avoid
|
||||||
|
adding to. Six of them are `import.meta` errors that Phase 1 resolves as a side
|
||||||
|
effect of configuring `vite/client`.
|
||||||
|
|
||||||
|
Note that `npm run typecheck` **is not run by CI** (`.github/workflows/ci.yml`
|
||||||
|
runs lint, test, build and the seed check). That is how 71 errors accumulated
|
||||||
|
without anyone seeing them. Wiring it in is the last phase of the migration, not
|
||||||
|
the first — it cannot be a gate until it is green.
|
||||||
|
|
||||||
|
## The standalone Owliver script is already broken
|
||||||
|
|
||||||
|
`node scripts/owliver-baseline.mjs` fails before any of this began — verified by
|
||||||
|
stashing every change and running it on a clean `main`, where it fails
|
||||||
|
identically. It is **not** caused by the migration.
|
||||||
|
|
||||||
|
```
|
||||||
|
Failed to scan for dependencies from entries:
|
||||||
|
index.html
|
||||||
|
scripts/__baseline__/activity-page.pre-migration.html
|
||||||
|
… the other seven snapshots …
|
||||||
|
✘ [ERROR] The server is being restarted or closed. Request is outdated [plugin vite:dep-scan]
|
||||||
|
Owliver behaviour has DRIFTED from the baseline.
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things are going on, and neither is a real drift. Vite's dependency scanner
|
||||||
|
treats every `*.html` under the project root as an entry point, so the eight
|
||||||
|
captured SSR snapshots in `scripts/__baseline__/` are scanned as if they were
|
||||||
|
pages of the app. The script then calls `server.close()` as soon as
|
||||||
|
`captureBaseline` returns, while that scan is still running, and the scan
|
||||||
|
reports that it was cancelled. The capture comes back incomplete and the
|
||||||
|
comparison calls it drift.
|
||||||
|
|
||||||
|
**The coverage itself is not lost.** `skill-check.mjs` imports the same
|
||||||
|
`captureBaseline` and the same `BASELINE_PATH` and asserts against them in
|
||||||
|
section 21, and it does enough other work afterwards that the scan finishes. Run
|
||||||
|
`npm test` and the whole section is green:
|
||||||
|
|
||||||
|
```
|
||||||
|
── Owliver behaviour baseline ──
|
||||||
|
[ ok ] baseline schema matches — expected 1, got 1
|
||||||
|
[ ok ] every skill that existed before the agent layer still registers — 23 kept, 1 added since
|
||||||
|
[ ok ] every route that resolved before resolves the same way — 18 unchanged
|
||||||
|
[ ok ] controlCenter: keeps every skill it had — 7 skill(s), unchanged
|
||||||
|
…per context: page key, skills, suggestions, prompts, intent routing
|
||||||
|
```
|
||||||
|
|
||||||
|
So **`npm test` is the Owliver drift check for the duration of the migration**,
|
||||||
|
and the standalone script is a convenience wrapper that needs repairing on its
|
||||||
|
own terms — excluding `scripts/__baseline__` from the scan, or awaiting the
|
||||||
|
optimizer before closing. That repair is not part of the migration: it changes
|
||||||
|
a script's behaviour, and this milestone's whole claim is that it changed none.
|
||||||
|
|
||||||
|
## The one failing check
|
||||||
|
|
||||||
|
```
|
||||||
|
1641/1642 checks passed
|
||||||
|
|
||||||
|
Failed:
|
||||||
|
- the backend fixture is in step with this seed (stale — run `npm run seed:fixture`)
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/__baseline__` is intact and `owliver-baseline.mjs` matches; this is not
|
||||||
|
an Owliver failure. `seed/fixtures/seed.json` in the sibling `krow-backend`
|
||||||
|
checkout has drifted from `src/api/seed.js`.
|
||||||
|
|
||||||
|
**This failure predates the migration and is unrelated to it. It is recorded
|
||||||
|
here so that it is never mistaken for something the migration caused, and it is
|
||||||
|
deliberately left alone** — regenerating the fixture would write into another
|
||||||
|
repository and change what the backend seeds, which is a product change wearing
|
||||||
|
a migration's clothes. Fix it separately, on its own terms.
|
||||||
|
|
||||||
|
## What "unchanged" means for the Owliver / agent flow
|
||||||
|
|
||||||
|
The agent flow is ~112 files and 38% of the codebase, and it is converted last,
|
||||||
|
by rename and type annotation only. The evidence that it still behaves
|
||||||
|
identically is already in the repository and needs nothing new:
|
||||||
|
|
||||||
|
- `npm test` — 1642 checks covering skill discovery, routing, context
|
||||||
|
resolution and SSR-rendered markup.
|
||||||
|
- `scripts/__baseline__/owliver-baseline.json` — contexts, skill ids and routes.
|
||||||
|
- `scripts/__baseline__/*.pre-migration.html` — eight pages of byte-level SSR
|
||||||
|
output.
|
||||||
|
- `node scripts/owliver-baseline.mjs` — the drift check.
|
||||||
|
- CI's `FLOOR = 900` — fails if the suite silently shrinks.
|
||||||
|
|
||||||
|
**These artifacts are read-only for the duration of the migration.** If the
|
||||||
|
baseline drifts, revert the change. Regenerating it to clear a red check records
|
||||||
|
the regression instead of catching it, which is worse than having no baseline —
|
||||||
|
`owliver-baseline.mjs` says as much in its own header.
|
||||||
|
|
||||||
|
`src/agents/**/*.md` and `src/skills/**/*.md` are likewise untouched throughout:
|
||||||
|
they are the definitions the flow is built from, not code being migrated.
|
||||||
|
|
||||||
|
## How to re-measure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck; echo "exit=$?"
|
||||||
|
npm run typecheck 2>&1 | grep -c "error TS"
|
||||||
|
npm run lint; echo "exit=$?"
|
||||||
|
npx eslint . --format json | python3 -c "import json,sys;print(len(json.load(sys.stdin)))"
|
||||||
|
npm test
|
||||||
|
node scripts/owliver-baseline.mjs
|
||||||
|
npm run build
|
||||||
|
git diff --stat -M
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-20 — closing addendum: the migration is complete
|
||||||
|
|
||||||
|
Everything above this line is the record as measured on 2026-09-11 and is left
|
||||||
|
exactly as it was written. The numbers in it describe `main` before the first
|
||||||
|
rename; they are the thing the migration was checked against, so correcting them
|
||||||
|
to today's figures would destroy the comparison rather than update it. What
|
||||||
|
follows is the other end of that comparison.
|
||||||
|
|
||||||
|
### Final state, measured on the `ts-migration` branch
|
||||||
|
|
||||||
|
| Check | Command | 2026-09-11 baseline | Now |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Types | `npm run typecheck` | FAILS — 71 errors / 27 files | **0 errors**, 321 files in the program |
|
||||||
|
| Lint | `npm run lint` | PASSES, exit 0 | PASSES, exit 0 |
|
||||||
|
| Lint (with warnings) | `npx eslint .` | 0 errors, 289 warnings | 0 errors, 289 warnings |
|
||||||
|
| Behaviour | `npm test` | 1641 / 1642 | **1693 / 1693** |
|
||||||
|
| Build | `npm run build` | succeeds | succeeds |
|
||||||
|
| Bundle | `dist/assets/index-*.js` | — | `74d17e2d5cafdd6f88eaf6d89ffdab11` |
|
||||||
|
| Owliver, standalone | `node scripts/owliver-baseline.mjs` | FAILS — pre-existing | **matches the baseline** |
|
||||||
|
| Backend fixture | `npm run seed:check` | FAILS — stale | **in step** |
|
||||||
|
|
||||||
|
No `.js` or `.jsx` remains under `src/`: 103 `.ts`, 216 `.tsx` and 2 `.d.ts`.
|
||||||
|
`src/agents/**/*.md` and `src/skills/**/*.md` are untouched, as promised.
|
||||||
|
|
||||||
|
### The three items this document left open, and where they were closed
|
||||||
|
|
||||||
|
**The standalone Owliver script.** Recorded above as failing before any of this
|
||||||
|
began, verified by running it on a clean `main`. The byte-exact comparison was
|
||||||
|
reporting one real difference: the baseline held 23 skills and the runtime
|
||||||
|
resolved 24, because `create-employee-role` was added after the baseline was
|
||||||
|
last written and nobody recaptured it. `npm test` tolerated it — its check
|
||||||
|
asserts only that nothing was *removed*, and read "23 kept, 1 added since" — so
|
||||||
|
the drift was visible only to the stricter script. Refreshed in `34505d7`:
|
||||||
|
three inserted lines, no deletions, routes byte-identical at 18 and all eleven
|
||||||
|
contexts unchanged. The check now reads "24 kept, 0 added since".
|
||||||
|
|
||||||
|
**The backend seed fixture.** Recorded above as predating the migration and
|
||||||
|
deliberately left alone. It stayed that way for good reason: the generator was a
|
||||||
|
version behind the seeder and emitted no `users` key, so regenerating would have
|
||||||
|
dropped `employer@krow.app` from every fresh seed and left nobody to sign in as
|
||||||
|
to reach the employer console. Fixed in `3ddacf2` by teaching the generator to
|
||||||
|
emit the key, not by overwriting the fixture — with that change the generator
|
||||||
|
reproduces the committed fixture byte for byte, so `seed.json` in the sibling
|
||||||
|
repository was never written to at all.
|
||||||
|
|
||||||
|
**The HTML render baselines.** Recorded above as read-only for the duration of
|
||||||
|
the migration, and that held: they were untouched through every phase. Three of
|
||||||
|
them — Hired History, Analytics and Candidates — were recaptured in `878a235`,
|
||||||
|
after the migration, because a parallel feature removed demo data those files had
|
||||||
|
captured while it was still in effect. They are renamed `*.render.html`, since a
|
||||||
|
file called `pre-migration` holding post-feature markup is a lie in the filename.
|
||||||
|
The other five still hold genuine pre-migration markup, still pass, and keep the
|
||||||
|
name. One check that could no longer be satisfied was replaced by three that read
|
||||||
|
the render directly; `scripts/__baseline__/README.md` has the detail.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
3ddacf2 fix(seed): emit the users array the backend seeder reads
|
||||||
|
878a235 test(baseline): recapture the three HTML baselines that held invented people
|
||||||
|
34505d7 chore: refresh owliver baseline
|
||||||
|
|
||||||
|
Strictness remains as debt rather than a blocker: `strict: true` reports 4438
|
||||||
|
errors, `noImplicitAny` 3317 and `strictNullChecks` 1794, while the committed
|
||||||
|
configuration reports none. None of it affects emitted output — the production
|
||||||
|
bundle hash is unchanged — so it is hardening work, not migration work.
|
||||||
90
README.md
90
README.md
@@ -19,22 +19,22 @@ Recharts + MUI X Charts · react-hot-toast.
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
main.jsx entry — mounts <App/>, imports index.css
|
main.tsx entry — mounts <App/>, imports index.css
|
||||||
App.jsx providers + the entire route table
|
App.tsx providers + the entire route table
|
||||||
index.css design tokens (HSL CSS variables) + utility layers
|
index.css design tokens (HSL CSS variables) + utility layers
|
||||||
|
|
||||||
api/ the backend seam — see below
|
api/ the backend seam — see below
|
||||||
base44Client.js the contract the production app talks to
|
base44Client.ts the contract the production app talks to
|
||||||
store.js in-memory entity store, mirrored to localStorage
|
store.js in-memory entity store, mirrored to localStorage
|
||||||
aiEngine.js local, deterministic InvokeLLM
|
aiEngine.ts local, deterministic InvokeLLM
|
||||||
seed.js demo dataset
|
seed.ts demo dataset
|
||||||
attendanceSeed.js shift/attendance dataset
|
attendanceSeed.ts shift/attendance dataset
|
||||||
|
|
||||||
lib/ domain logic and data hooks
|
lib/ domain logic and data hooks
|
||||||
krowHooks.js React Query hooks over the entity store
|
krowHooks.ts React Query hooks over the entity store
|
||||||
krowAi.js AI workflows
|
krowAi.ts AI workflows
|
||||||
krowScore.js score engine
|
krowScore.ts score engine
|
||||||
AuthContext.jsx auth provider
|
AuthContext.tsx auth provider
|
||||||
admin/ admin session, permissions, position insights
|
admin/ admin session, permissions, position insights
|
||||||
skills/ the Owliver skill system (registry, resolver, flows)
|
skills/ the Owliver skill system (registry, resolver, flows)
|
||||||
agents/ the agent system (registry, runtime, lifecycle)
|
agents/ the agent system (registry, runtime, lifecycle)
|
||||||
@@ -79,37 +79,37 @@ The app opens on **`/admin/login`**. Everything else lives under `/admin/*` behi
|
|||||||
| `/admin/workspace/skills`, `skills/new`, `skills/:id` | UI skill list and editor |
|
| `/admin/workspace/skills`, `skills/new`, `skills/:id` | UI skill list and editor |
|
||||||
| `/admin/workspace/skills/owliver/new`, `owliver/:id` | Owliver skill editor |
|
| `/admin/workspace/skills/owliver/new`, `owliver/:id` | Owliver skill editor |
|
||||||
| `/admin/workspace/skill-development` | Skill Development |
|
| `/admin/workspace/skill-development` | Skill Development |
|
||||||
| `*` | `lib/PageNotFound.jsx` |
|
| `*` | `lib/PageNotFound.tsx` |
|
||||||
|
|
||||||
Legacy top-level paths (`/overview`, `/positions`, `/candidates`, `/hired`, `/talent-pool`,
|
Legacy top-level paths (`/overview`, `/positions`, `/candidates`, `/hired`, `/talent-pool`,
|
||||||
`/university`, `/analytics`, `/activity`, `/tracking`) all `Navigate` into their `/admin/*`
|
`/university`, `/analytics`, `/activity`, `/tracking`) all `Navigate` into their `/admin/*`
|
||||||
equivalent, id preserved. Static route segments are declared before dynamic ones so
|
equivalent, id preserved. Static route segments are declared before dynamic ones so
|
||||||
`workspace/agents/new` cannot be read as an agent whose id is `"new"`.
|
`workspace/agents/new` cannot be read as an agent whose id is `"new"`.
|
||||||
|
|
||||||
> **Note.** `App.jsx` still imports a set of pre-redesign Employer/Talent pages
|
> **Note.** `App.tsx` still imports a set of pre-redesign Employer/Talent pages
|
||||||
> (`Overview`, `Apply`, `WorkerProfile`, `KrowIdentity`, `EmployeeDashboard`, `DesignSystem`, …)
|
> (`Overview`, `Apply`, `WorkerProfile`, `KrowIdentity`, `EmployeeDashboard`, `DesignSystem`, …)
|
||||||
> and `layouts/Layout.jsx`. None of them are mounted on a route — they are unreachable at runtime
|
> and `layouts/Layout.tsx`. None of them are mounted on a route — they are unreachable at runtime
|
||||||
> and retained pending a decision on whether to re-route or remove them.
|
> and retained pending a decision on whether to re-route or remove them.
|
||||||
|
|
||||||
## The backend seam
|
## The backend seam
|
||||||
|
|
||||||
The production app talks to a Base44 backend through `src/api/base44Client.js`. This demo keeps that
|
The production app talks to a Base44 backend through `src/api/base44Client.ts`. This demo keeps that
|
||||||
module path, export name and full method contract, and swaps only the transport:
|
module path, export name and full method contract, and swaps only the transport:
|
||||||
|
|
||||||
| Contract | Demo implementation |
|
| Contract | Demo implementation |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `base44.entities.<Name>.list/filter/get/create/update/delete` | `api/store.js` — in-memory records, mirrored to `localStorage` |
|
| `base44.entities.<Name>.list/filter/get/create/update/delete` | `api/store.js` — in-memory records, mirrored to `localStorage` |
|
||||||
| `base44.integrations.Core.InvokeLLM` | `api/aiEngine.js` — local, deterministic |
|
| `base44.integrations.Core.InvokeLLM` | `api/aiEngine.ts` — local, deterministic |
|
||||||
| `base44.integrations.Core.UploadFile` | blob URLs, session-scoped |
|
| `base44.integrations.Core.UploadFile` | blob URLs, session-scoped |
|
||||||
| `base44.auth.*` | seeded employer/admin user, always signed in |
|
| `base44.auth.*` | seeded employer/admin user, always signed in |
|
||||||
|
|
||||||
Because the seam did not move, everything above it — `lib/krowHooks.js`, `lib/krowAi.js`,
|
Because the seam did not move, everything above it — `lib/krowHooks.ts`, `lib/krowAi.ts`,
|
||||||
`lib/krowScore.js`, every page and component — is unchanged from the reference implementation.
|
`lib/krowScore.ts`, every page and component — is unchanged from the reference implementation.
|
||||||
|
|
||||||
## The AI engine
|
## The AI engine
|
||||||
|
|
||||||
Every AI workflow funnels through one call, `InvokeLLM({ prompt, response_json_schema })`.
|
Every AI workflow funnels through one call, `InvokeLLM({ prompt, response_json_schema })`.
|
||||||
`api/aiEngine.js` reimplements it locally: it recognises each workflow by the phrase its prompt opens
|
`api/aiEngine.ts` reimplements it locally: it recognises each workflow by the phrase its prompt opens
|
||||||
with, reads the structured fields the prompt already carries (`Years Experience: 6`,
|
with, reads the structured fields the prompt already carries (`Years Experience: 6`,
|
||||||
`Required Certifications: …`, the talent-pool JSON block) and scores them deterministically.
|
`Required Certifications: …`, the talent-pool JSON block) and scores them deterministically.
|
||||||
|
|
||||||
@@ -124,8 +124,8 @@ Two registries discover their definitions from markdown at build time:
|
|||||||
|
|
||||||
| Registry | Glob | Files |
|
| Registry | Glob | Files |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `lib/skills/registry.js` | `import.meta.glob('/src/skills/**/*.md')` | 18 Owliver skills, 5 workforce training skills |
|
| `lib/skills/registry.ts` | `import.meta.glob('/src/skills/**/*.md')` | 19 Owliver skills, 5 workforce training skills |
|
||||||
| `lib/agents/registry.js` | `import.meta.glob('/src/agents/**/*.md')` | 9 agent definitions |
|
| `lib/agents/registry.ts` | `import.meta.glob('/src/agents/**/*.md')` | 9 agent definitions |
|
||||||
|
|
||||||
**These globs are absolute paths.** Moving or renaming `src/skills/` or `src/agents/` makes the
|
**These globs are absolute paths.** Moving or renaming `src/skills/` or `src/agents/` makes the
|
||||||
registry silently return nothing — no build error, no import failure, just an empty registry. Add
|
registry silently return nothing — no build error, no import failure, just an empty registry. Add
|
||||||
@@ -133,9 +133,9 @@ definitions by dropping a new `.md` file into the right folder; nothing else nee
|
|||||||
|
|
||||||
## Demo data
|
## Demo data
|
||||||
|
|
||||||
Seeded in `src/api/seed.js` — positions, applicants, AI-scored candidates, interviews, hires,
|
Seeded in `src/api/seed.ts` — positions, applicants, AI-scored candidates, interviews, hires,
|
||||||
talent-pool profiles, Proving Ground courses and an activity log; shift data in
|
talent-pool profiles, Proving Ground courses and an activity log; shift data in
|
||||||
`src/api/attendanceSeed.js`.
|
`src/api/attendanceSeed.ts`.
|
||||||
|
|
||||||
Edits persist to `localStorage`. To restore the shipped dataset:
|
Edits persist to `localStorage`. To restore the shipped dataset:
|
||||||
|
|
||||||
@@ -163,12 +163,12 @@ Tokens live in `src/index.css` as HSL CSS variables consumed by `tailwind.config
|
|||||||
| Body | Inter |
|
| Body | Inter |
|
||||||
|
|
||||||
`src/components/ds/` is the vocabulary the app actually writes in — `Surface`, `KpiCard`,
|
`src/components/ds/` is the vocabulary the app actually writes in — `Surface`, `KpiCard`,
|
||||||
`DataTable`, `PageHeader`, `Modal`, `toast` and the rest, exported through `ds/index.js`.
|
`DataTable`, `PageHeader`, `Modal`, `toast` and the rest, exported through `ds/index.ts`.
|
||||||
`src/components/ui/` holds the shadcn primitives those are built on. Surfaces use `glass` and
|
`src/components/ui/` holds the shadcn primitives those are built on. Surfaces use `glass` and
|
||||||
`glass-card`; the page background is the `gradient-mesh` utility.
|
`glass-card`; the page background is the `gradient-mesh` utility.
|
||||||
|
|
||||||
Toasts go through `ds/toast` (a wrapper over `react-hot-toast`), rendered by `<HotToaster>` in
|
Toasts go through `ds/toast` (a wrapper over `react-hot-toast`), rendered by `<HotToaster>` in
|
||||||
`App.jsx`.
|
`App.tsx`.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
@@ -179,8 +179,8 @@ Toasts go through `ds/toast` (a wrapper over `react-hot-toast`), rendered by `<H
|
|||||||
| `npm run preview` | Serve the build |
|
| `npm run preview` | Serve the build |
|
||||||
| `npm run lint` | ESLint — currently clean |
|
| `npm run lint` | ESLint — currently clean |
|
||||||
| `npm run lint:fix` | ESLint with `--fix` |
|
| `npm run lint:fix` | ESLint with `--fix` |
|
||||||
| `npm test` | `scripts/skill-check.mjs` — 835 assertions over the skill and agent systems |
|
| `npm test` | `scripts/skill-check.mjs` — 1693 assertions over the skill and agent systems |
|
||||||
| `npm run typecheck` | `tsc -p ./jsconfig.json` with `checkJs` |
|
| `npm run typecheck` | `tsc -p ./tsconfig.json` — 0 errors across 321 TypeScript files |
|
||||||
|
|
||||||
### The test harness
|
### The test harness
|
||||||
|
|
||||||
@@ -191,17 +191,41 @@ mocks. It also diffs Owliver's behaviour across eleven page contexts against a c
|
|||||||
`node scripts/owliver-baseline.mjs --write`, never to turn a red check green.
|
`node scripts/owliver-baseline.mjs --write`, never to turn a red check green.
|
||||||
|
|
||||||
Because it loads modules by absolute path, the harness pins the locations of `src/api/`, `src/lib/`,
|
Because it loads modules by absolute path, the harness pins the locations of `src/api/`, `src/lib/`,
|
||||||
`src/lib/skills/`, `src/lib/agents/`, `src/components/ai-assistant/`, `src/App.jsx`,
|
`src/lib/skills/`, `src/lib/agents/`, `src/components/ai-assistant/`, `src/App.tsx`,
|
||||||
`src/layouts/AdminLayout.jsx` and a handful of agent files. Move any of them and update
|
`src/layouts/AdminLayout.tsx` and a handful of agent files. Move any of them and update
|
||||||
`scripts/owliver-capture.mjs` and `scripts/skill-check.mjs` in the same change.
|
`scripts/owliver-capture.mjs` and `scripts/skill-check.mjs` in the same change.
|
||||||
|
|
||||||
### Known-failing checks
|
### Check status
|
||||||
|
|
||||||
Two checks fail on a clean checkout and are tracked as separate work, not regressions:
|
Everything above passes on a clean checkout: `npm test` is 1693/1693 and
|
||||||
|
`npm run typecheck` reports no errors.
|
||||||
|
|
||||||
- `npm test` — 834/835 pass; `the seeded overtime climb is found` fails.
|
This section used to list two known failures — 834/835 on the suite, and 59 type
|
||||||
- `npm run typecheck` — 59 errors, all pre-existing JSDoc/inference gaps in `lib/krowAi.js`,
|
errors across `lib/krowAi`, `lib/skills/*` and `lib/positionModel`. Both are
|
||||||
`lib/skills/*`, `lib/positionModel.js` and a few components.
|
gone, and they were unrelated to each other.
|
||||||
|
|
||||||
|
The suite failure was `the seeded overtime climb is found`. It asserted against
|
||||||
|
the live calendar: the shifts are generated by counting back from the day the
|
||||||
|
suite runs, so the oldest week in the window thinned as the week wore on and
|
||||||
|
inflated the baseline every later week was compared against. The climb was
|
||||||
|
reported Sunday through Thursday and vanished on Friday and Saturday. That was a
|
||||||
|
product defect rather than a flaky assertion, and it was fixed in
|
||||||
|
`src/lib/attendance.ts` (commit `88c412f`) by dropping a leading week rostered
|
||||||
|
well below the usual — only from the front, so a genuine collapse in the middle
|
||||||
|
is still a finding. `the seeded overtime climb is detectable on every day of the
|
||||||
|
week` re-runs the detector against 28 consecutive anchors and is what keeps it
|
||||||
|
fixed.
|
||||||
|
|
||||||
|
The type errors were resolved by the TypeScript migration.
|
||||||
|
|
||||||
|
A third check, `the backend fixture is in step with this seed`, failed for its
|
||||||
|
own reasons and is recorded here because it is easily confused with the above:
|
||||||
|
`seed.json` in the sibling backend had drifted from this seed module. Fixed in
|
||||||
|
commit `3ddacf2` by teaching the generator to emit the `users` array the Go
|
||||||
|
seeder reads, rather than by overwriting the fixture. All three pass.
|
||||||
|
|
||||||
|
The heading is kept rather than deleted so that the absence of failures is
|
||||||
|
stated rather than merely implied.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "new-york",
|
"style": "new-york",
|
||||||
"rsc": false,
|
"rsc": false,
|
||||||
"tsx": false,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.js",
|
"config": "tailwind.config.js",
|
||||||
"css": "src/index.css",
|
"css": "src/index.css",
|
||||||
|
|||||||
393
docs/instanced-ui-nodes.md
Normal file
393
docs/instanced-ui-nodes.md
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
# Repeated and instanced UI nodes — design
|
||||||
|
|
||||||
|
> **Status: design only. Nothing here is implemented.**
|
||||||
|
> The audit below is traced from the code as it stands; the design that follows
|
||||||
|
> is a proposal to be reviewed and tested before any of it is built.
|
||||||
|
|
||||||
|
Every page migrated so far is *flat*: each node in the composition renders
|
||||||
|
exactly once, so a node id and a rendering are the same thing. Positions is the
|
||||||
|
first surface where that is not true. Six of its eight extension points render
|
||||||
|
**inside a record** — once per position — and the card those records are drawn
|
||||||
|
in is hand-written JSX that the node system cannot see at all.
|
||||||
|
|
||||||
|
This document says what is there now, proposes a model for it, and is honest
|
||||||
|
about what the model costs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What is there now
|
||||||
|
|
||||||
|
### 1.1 The eight Positions placements, and which are instanced
|
||||||
|
|
||||||
|
`surfaces.js:47-76` declares eight placements under the `positions` surface and
|
||||||
|
`provides` already records the distinction that matters — which of them hand a
|
||||||
|
section a record:
|
||||||
|
|
||||||
|
| Placement | Rendered | Host | `provides` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `after-position-list-summary` | once per page | `Positions.jsx` (grid) | `[]` |
|
||||||
|
| `after-position-list` | once per page | `Positions.jsx` (grid) | `[]` |
|
||||||
|
| `after-position-card` | **once per record** | `PositionCard` | `positionId` |
|
||||||
|
| `after-header` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
|
||||||
|
| `after-position-summary` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
|
||||||
|
| `before-candidates` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
|
||||||
|
| `after-candidates` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
|
||||||
|
| `before-footer` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
|
||||||
|
|
||||||
|
Only the first two are in the node tree today (`pages/admin/positions/nodes.js`),
|
||||||
|
drawn through `UiNodeSlot`. The other six are still literal `<SkillSurface>`
|
||||||
|
elements.
|
||||||
|
|
||||||
|
### 1.2 The render loops
|
||||||
|
|
||||||
|
**The grid** — `Positions.jsx:1158`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{filtered.map((p) => (
|
||||||
|
<PositionCard key={p.id} position={p} onOpen={openPosition} isRecent={recentIds.has(p.id)} />
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
`filtered` is page state: the search box, the role and location selects and the
|
||||||
|
sort, applied in `useMemo`. It is not a data source in the `surfaces.js` sense
|
||||||
|
and has no entry in `DATA_SOURCES` — it is the page's own working set.
|
||||||
|
|
||||||
|
**Inside each card** — `Positions.jsx:626-641`. The card's last element, wrapped
|
||||||
|
in a click-stopping `div` so a section inside a card does not open the drawer
|
||||||
|
behind it:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<SkillSurface page="positions" placement="after-position-card"
|
||||||
|
context={{ position: p }} className="mt-3" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**The drawer** — `PositionDrawer` (`Positions.jsx:719, 729, 793, 870, 897`) draws
|
||||||
|
five surfaces, each with `context={{ position: p }}`, for the one position
|
||||||
|
`openPosition` selected. `PositionDetail.jsx:288, 421, 472, 512, 513` draws the
|
||||||
|
same five placements for the position named in the route.
|
||||||
|
|
||||||
|
### 1.3 How a record reaches a section
|
||||||
|
|
||||||
|
Unchanged all the way down, and already correct for this design:
|
||||||
|
|
||||||
|
```
|
||||||
|
<SkillSurface context={{ position: p }} />
|
||||||
|
→ useSkillDataContext(context) SkillSurface.jsx:76 { ...published, ...context, ...collections }
|
||||||
|
→ resolveSkillData(section, ctx) dataResolver.js:1192
|
||||||
|
if (section.context === 'positionId' && !context.position) → unavailable
|
||||||
|
RESOLVERS['position.pipeline']({ position, applications }) → filters by position.id
|
||||||
|
```
|
||||||
|
|
||||||
|
An explicit `context` prop beats what the page published, which is exactly the
|
||||||
|
rule an instanced node needs: *a card knows which position it is.*
|
||||||
|
|
||||||
|
### 1.4 Section resolution
|
||||||
|
|
||||||
|
`useSkillSections(page, placement)` (`SkillSurface.jsx:46`) is independent of the
|
||||||
|
record. It reads the account's active skills and returns
|
||||||
|
`{ skill, section }[]` for the placement. **The same list is used for every
|
||||||
|
card** — repetition happens below it, in data resolution, not in which sections
|
||||||
|
exist. That is what makes one template node correct.
|
||||||
|
|
||||||
|
### 1.5 DOM identity today
|
||||||
|
|
||||||
|
None inside a card. `<SkillSurface>` renders `div.space-y-4 > section[aria-label]`
|
||||||
|
with no `data-` attributes, and `SkillSection`'s React key
|
||||||
|
(`` `${skill.id}:${section.id}` ``) is not emitted. `PositionCard` renders an
|
||||||
|
`<article>` with no id. There is nothing in the document that says *which*
|
||||||
|
position a rendering belongs to.
|
||||||
|
|
||||||
|
### 1.6 The card body
|
||||||
|
|
||||||
|
`PositionCard` (`Positions.jsx:519-643`) is roughly 120 lines of tuned JSX:
|
||||||
|
role glyph and client/title/category block, status pill, a terms `<dl>`, one
|
||||||
|
line of candidate criteria, a hairline rule, `Progression`, `WorkforceRow`, a
|
||||||
|
health chip with the insight line, and a footer pinned with `mt-auto` so a row
|
||||||
|
of cards keeps its footers aligned. Plus a two-minute "just saved" treatment.
|
||||||
|
|
||||||
|
**None of it is addressable.** "Make all position cards compact" has nothing to
|
||||||
|
act on today, in either the node tree or the component: no density prop exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The model
|
||||||
|
|
||||||
|
### 2.1 One node, many renderings
|
||||||
|
|
||||||
|
The rule the whole design rests on:
|
||||||
|
|
||||||
|
> A repeated surface is **one node in the tree**. The tree holds the template
|
||||||
|
> once; the DOM holds N renderings of it.
|
||||||
|
|
||||||
|
An operation targets the node, so one operation changes every card — which is
|
||||||
|
the "do not duplicate operations once per record" requirement met in the stored
|
||||||
|
bytes, not by a de-duplication pass.
|
||||||
|
|
||||||
|
### 2.2 Repetition is declared by the type, never by a patch
|
||||||
|
|
||||||
|
```js
|
||||||
|
registerNodeType({
|
||||||
|
type: 'position-card',
|
||||||
|
label: 'Position card',
|
||||||
|
component: PositionCardNode,
|
||||||
|
container: true,
|
||||||
|
accepts: ['skill-surface', ...],
|
||||||
|
repeats: {
|
||||||
|
from: 'positions', // a key in the bag the PAGE publishes to UiRenderContext
|
||||||
|
as: 'position', // the context key each rendering is given
|
||||||
|
key: 'id', // the record field that identifies a rendering
|
||||||
|
},
|
||||||
|
propSchema: { density: { enum: ['comfortable', 'compact'] } },
|
||||||
|
capabilities: ['update', 'move', 'hide', 'reorder'],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`repeats` lives on the **registration**, which is code, and never on the node —
|
||||||
|
so it is absent from `OP_FIELDS`, cannot be written by a stored patch, and gives
|
||||||
|
the validator nothing new to police on user data. A patch can change what a card
|
||||||
|
looks like; it can never change what a card iterates over.
|
||||||
|
|
||||||
|
`from` names a key in the page's own `UiRenderContext` bag
|
||||||
|
(`UiTreeRenderer.jsx:39`) rather than a `DATA_SOURCES` entry, because `filtered`
|
||||||
|
*is* page state — search, filters and sort applied. This adds no new data
|
||||||
|
pathway: the renderer reads `useUiContext()[entry.repeats.from]` and still never
|
||||||
|
looks inside the bag on its own account.
|
||||||
|
|
||||||
|
### 2.3 Node schema — unchanged
|
||||||
|
|
||||||
|
No new field on `UiNode`. A repeater is an ordinary container node whose *type*
|
||||||
|
happens to repeat:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id: 'position-card',
|
||||||
|
type: 'position-card',
|
||||||
|
props: { density: 'comfortable' },
|
||||||
|
layout: { ... },
|
||||||
|
children: [ /* the template subtree */ ],
|
||||||
|
hidden: false, origin: 'builtin', locked: false,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The template's children are ordinary nodes with ordinary ids
|
||||||
|
(`position-card-terms`, `position-card-extensions`, …). Each is one node and N
|
||||||
|
renderings, by the same rule.
|
||||||
|
|
||||||
|
### 2.4 DOM identity
|
||||||
|
|
||||||
|
```html
|
||||||
|
<article data-ui-node="position-card" data-ui-instance="pos_123"> … </article>
|
||||||
|
<article data-ui-node="position-card" data-ui-instance="pos_456"> … </article>
|
||||||
|
```
|
||||||
|
|
||||||
|
`data-ui-node` keeps meaning **the node** — one value, N elements. `data-ui-instance`
|
||||||
|
carries the record key from `repeats.key`. Descendants of a rendering inherit the
|
||||||
|
instance from their ancestor rather than repeating it, so the pair
|
||||||
|
(`data-ui-node`, nearest ancestor `data-ui-instance`) addresses exactly one
|
||||||
|
rendering.
|
||||||
|
|
||||||
|
This **breaks the current invariant that `data-ui-node` is unique in a
|
||||||
|
document**, and everything that assumes it must be found and changed. See §5.1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Targeting semantics
|
||||||
|
|
||||||
|
Three scopes. Only the first is proposed for implementation now.
|
||||||
|
|
||||||
|
### 3.1 Template scope — the default
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "op": "update", "target": "position-card", "props": { "density": "compact" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
No new field. Applies to the node, therefore to every rendering. This is
|
||||||
|
"make all position cards compact", and it is one operation regardless of how
|
||||||
|
many positions exist.
|
||||||
|
|
||||||
|
Everything already true stays true: the op is validated against the type's
|
||||||
|
`propSchema` and `capabilities`, refused if `density` is not a declared enum
|
||||||
|
value, and stored in `uiLayouts` like any other.
|
||||||
|
|
||||||
|
### 3.2 Instance scope — designed, not built
|
||||||
|
|
||||||
|
An optional `scope` on the **operation**, not on the node:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "op": "update", "target": "position-card",
|
||||||
|
"scope": { "key": "pos_123" },
|
||||||
|
"props": { "density": "comfortable" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
The tree stays one template. `applyPatch` partitions:
|
||||||
|
|
||||||
|
- **unscoped ops** are applied to the tree as they are today;
|
||||||
|
- **scoped ops** are attached to their target node, grouped by key, and applied
|
||||||
|
at render time to that one rendering — by `applyOperations`, the same engine,
|
||||||
|
with the same validator.
|
||||||
|
|
||||||
|
There is no second mutation engine and no per-record tree in storage.
|
||||||
|
|
||||||
|
**Legality is declared per operation, not per page.** Each entry in `OPERATIONS`
|
||||||
|
gains `instanceable: true|false`:
|
||||||
|
|
||||||
|
| Operation | Instanceable | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `update` | yes | changes one rendering |
|
||||||
|
| `hide` | yes | changes one rendering |
|
||||||
|
| `move`, `reorder` | no (first cut) | one card structurally unlike its neighbours |
|
||||||
|
| `add`, `remove`, `replace` | no (first cut) | "remove this record's card" is a filter, not a layout change |
|
||||||
|
|
||||||
|
A `scope` on a node whose type does not declare `repeats` is refused — a registry
|
||||||
|
read, not a page branch.
|
||||||
|
|
||||||
|
### 3.3 Predicate scope — reserved, not designed
|
||||||
|
|
||||||
|
`scope: { where: [...] }` — "make all *draft* position cards compact". Named here
|
||||||
|
only so `scope` is an object from the first day rather than a bare key string
|
||||||
|
that would have to be widened later.
|
||||||
|
|
||||||
|
### 3.4 Natural language
|
||||||
|
|
||||||
|
Template scope needs nothing new: `resolveTarget` already scores "the position
|
||||||
|
card" against node titles, labels and ids, and there is exactly one such node.
|
||||||
|
|
||||||
|
Instance scope needs a record resolver — "this position's card" is only
|
||||||
|
answerable when a position is selected, which `PageContext` publishes for the
|
||||||
|
drawer but not for the grid. **Deferred.** Until then Owliver refuses instance
|
||||||
|
phrasing explicitly rather than silently widening it to every card, which is the
|
||||||
|
failure worth guarding hardest: a person who says "only this one" must never get
|
||||||
|
"all of them".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Persistence
|
||||||
|
|
||||||
|
### 4.1 Shape
|
||||||
|
|
||||||
|
No new store, no new key, no new tier. `preferences.uiLayouts[page].ops` gains an
|
||||||
|
optional `scope` per op:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 2,
|
||||||
|
"page": "positions",
|
||||||
|
"updatedAt": "…",
|
||||||
|
"ops": [
|
||||||
|
{ "op": "update", "target": "position-card", "props": { "density": "compact" } },
|
||||||
|
{ "op": "hide", "target": "position-card-pay", "hidden": true },
|
||||||
|
{ "op": "update", "target": "position-card",
|
||||||
|
"scope": { "key": "pos_123" }, "props": { "density": "comfortable" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Size is **O(operations), not O(records)**. One op makes every card compact.
|
||||||
|
|
||||||
|
### 4.2 The schema bump is not optional
|
||||||
|
|
||||||
|
`OP_FIELDS` (`patch.js:51`) whitelists the fields each op may carry and
|
||||||
|
`normalizeOp` **silently drops anything else**. A build that predates `scope`
|
||||||
|
would therefore read the third op above as an unscoped one and make *every* card
|
||||||
|
comfortable — the exact "only this one became all of them" failure.
|
||||||
|
|
||||||
|
So:
|
||||||
|
|
||||||
|
- a patch containing **no** scoped op keeps `schema: 1` and every existing build
|
||||||
|
reads it exactly as it does today;
|
||||||
|
- the first scoped op written raises that page's patch to `schema: 2`;
|
||||||
|
- a reader that does not understand a schema **skips the whole patch** and
|
||||||
|
reports it through the existing `skipped` channel, rather than applying part
|
||||||
|
of it.
|
||||||
|
|
||||||
|
### 4.3 Stale keys
|
||||||
|
|
||||||
|
A scoped op naming a record that no longer exists is reported as `skipped`, like
|
||||||
|
any stale target, and **retained** rather than dropped. Retained deliberately:
|
||||||
|
the record may simply be filtered out of `filtered` by the page's own search or
|
||||||
|
role filter at that moment, and treating the page's filter state as deletion
|
||||||
|
would quietly destroy a person's saved overrides every time they typed in a
|
||||||
|
search box. Clearing them is an explicit action in the editor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Risks
|
||||||
|
|
||||||
|
**5.1 `data-ui-node` stops being unique.** Any `querySelector` on it silently
|
||||||
|
takes the first rendering — in tests, in the editor, and in anything built later.
|
||||||
|
*Mitigation:* replace the uniqueness assertion with the real invariant — ids are
|
||||||
|
unique unless the node's type declares `repeats`, and (`data-ui-node`, ancestor
|
||||||
|
`data-ui-instance`) is unique always. This is a test to write **before** the
|
||||||
|
first repeater exists.
|
||||||
|
|
||||||
|
**5.2 Render cost.** Instance-scoped ops run the operation engine per rendering.
|
||||||
|
Negligible for a handful of overrides over tens of cards; not negligible for
|
||||||
|
hundreds of records. *Mitigation:* only records that actually carry a scoped op
|
||||||
|
do any work, memoized on (template, key, ops hash).
|
||||||
|
|
||||||
|
**5.3 Migrating `PositionCard` is a real rewrite, and the riskiest one so far.**
|
||||||
|
`mt-auto` footer alignment, truncation, the hover and focus rings, and the
|
||||||
|
"just saved" animation are all tuned. Phase 5A already produced one margin
|
||||||
|
regression on a far simpler surface. *Mitigation:* the same baseline capture
|
||||||
|
used for every other migration, run over the grid with a fixed record set, plus
|
||||||
|
the class-signature and word-signature checks.
|
||||||
|
|
||||||
|
**5.4 "Compact" does not exist yet.** No component honours a density prop. The
|
||||||
|
engine cannot invent one: the schema is what makes a change *expressible*, not
|
||||||
|
what makes it *possible*. Someone must implement two densities in `PositionCard`
|
||||||
|
before "make all position cards compact" can do anything, and until then the
|
||||||
|
honest answer to that request is that the card offers no such setting.
|
||||||
|
|
||||||
|
**5.5 The drawer and the detail page draw the same five placements.** They are
|
||||||
|
never on screen together — the drawer overlays `/admin/positions`, the detail
|
||||||
|
page is `/admin/positions/:id` — but they are different layouts around identical
|
||||||
|
extension points. If both render one composition, a change made in the drawer
|
||||||
|
also changes the detail page, which may surprise. *Proposal:* one composition
|
||||||
|
(`position-detail`) drawn by both hosts, editable from the detail route only in
|
||||||
|
the first cut, because an editing session currently holds a single composition
|
||||||
|
keyed to the route. Making a session span two compositions is the extension, and
|
||||||
|
`uiLayouts` already stores per page so it needs no storage change.
|
||||||
|
|
||||||
|
**5.6 Per-record overrides invite chaos.** Twenty individually tweaked cards is
|
||||||
|
unreviewable. *Mitigation:* keep instance scope to `update` and `hide`, show the
|
||||||
|
count of overrides on the node in the editor, and offer to clear them all.
|
||||||
|
|
||||||
|
**5.7 `repeats.from` is a page-state key, so a rename fails at run time.** The
|
||||||
|
page renames its context key, the repeater finds nothing, the grid renders empty
|
||||||
|
— with no build error. *Mitigation:* a check-script assertion that every
|
||||||
|
registered `repeats.from` is a key the owning page publishes, in the same shape
|
||||||
|
as the surface-route guard.
|
||||||
|
|
||||||
|
**5.8 Skill sections inside a repeater lose id uniqueness too.** A section under
|
||||||
|
`after-position-card` is one node and N renderings, each resolving against a
|
||||||
|
different `position`. Consistent with the model, and `SkillSurface`'s contract is
|
||||||
|
unchanged — `as: 'position'` feeds exactly the `context={{ position }}` prop it
|
||||||
|
already takes — but it is the same uniqueness caveat as §5.1 reaching the skill
|
||||||
|
layer.
|
||||||
|
|
||||||
|
**5.9 The MD schema is untouched.** No frontmatter key, no `normalizeSection`
|
||||||
|
change, therefore no Go parser change and no oracle regeneration. Confirmed by
|
||||||
|
construction: nothing in this design is authored in Markdown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. What must be tested before any of it is built
|
||||||
|
|
||||||
|
Against the *existing* Positions architecture, so the semantics are proven
|
||||||
|
before the card is touched:
|
||||||
|
|
||||||
|
1. One `update` on a repeater node changes every rendering — asserted on
|
||||||
|
rendering count, not on one element.
|
||||||
|
2. That change is **one** operation in `uiLayouts`, with a record count > 1.
|
||||||
|
3. A scoped `update` changes exactly one rendering and leaves the others.
|
||||||
|
4. A scoped op on a non-repeating node is refused.
|
||||||
|
5. A `move`/`remove`/`add`/`replace` carrying a `scope` is refused.
|
||||||
|
6. A schema-2 patch read by a schema-1 reader is skipped whole, never widened.
|
||||||
|
7. A scoped op naming an absent record is reported skipped and **retained**.
|
||||||
|
8. Filtering the grid does not drop overrides for the filtered-out records.
|
||||||
|
9. `data-ui-node` × ancestor `data-ui-instance` is unique; ids repeat only for
|
||||||
|
types declaring `repeats`.
|
||||||
|
10. Owliver refuses instance phrasing rather than widening it to the template.
|
||||||
|
11. A skill section under `after-position-card` resolves against its own card's
|
||||||
|
position, in every rendering.
|
||||||
|
12. The Positions grid renders identically to its captured baseline.
|
||||||
136
eslint.config.js
136
eslint.config.js
@@ -3,16 +3,68 @@ import pluginJs from "@eslint/js";
|
|||||||
import pluginReact from "eslint-plugin-react";
|
import pluginReact from "eslint-plugin-react";
|
||||||
import pluginReactHooks from "eslint-plugin-react-hooks";
|
import pluginReactHooks from "eslint-plugin-react-hooks";
|
||||||
import pluginUnusedImports from "eslint-plugin-unused-imports";
|
import pluginUnusedImports from "eslint-plugin-unused-imports";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule set, written once and applied to JavaScript and TypeScript alike.
|
||||||
|
*
|
||||||
|
* Shared rather than duplicated because the two blocks below differ in exactly
|
||||||
|
* one thing — which parser reads the file — and a rule that applied to `.jsx`
|
||||||
|
* but not to its `.tsx` successor would make the TypeScript migration look like
|
||||||
|
* it was tidying the code up. It is not; it renames files and adds types.
|
||||||
|
*/
|
||||||
|
const rules = {
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"react/jsx-uses-vars": "error",
|
||||||
|
"react/jsx-uses-react": "error",
|
||||||
|
"unused-imports/no-unused-imports": "error",
|
||||||
|
"unused-imports/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
vars: "all",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
args: "after-used",
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"react/no-unknown-property": [
|
||||||
|
"error",
|
||||||
|
{ ignore: ["cmdk-input-wrapper", "toast-close"] },
|
||||||
|
],
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
const plugins = {
|
||||||
|
react: pluginReact,
|
||||||
|
"react-hooks": pluginReactHooks,
|
||||||
|
"unused-imports": pluginUnusedImports,
|
||||||
|
};
|
||||||
|
|
||||||
|
const settings = { react: { version: "detect" } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which files are linted.
|
||||||
|
*
|
||||||
|
* Unchanged from what this config has always covered — `src/lib` and
|
||||||
|
* `src/components/ui` stay out of it — with `ts` and `tsx` added to every
|
||||||
|
* pattern. That addition is the point: ESLint matches on extension, so the
|
||||||
|
* moment a `.jsx` file became `.tsx` it would have dropped out of the run
|
||||||
|
* silently, and `eslint .` would have gone on exiting 0 while linting less and
|
||||||
|
* less of the codebase. A lint that passes because it checked nothing is worse
|
||||||
|
* than one that fails.
|
||||||
|
*/
|
||||||
|
const directories = ["src/components", "src/pages", "src/layouts", "src/hooks"];
|
||||||
|
const ignores = ["src/lib/**/*", "src/components/ui/**/*"];
|
||||||
|
|
||||||
|
const filesWith = (extensions) =>
|
||||||
|
directories.map((directory) => `${directory}/**/*.{${extensions}}`);
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
files: [
|
files: filesWith("js,mjs,cjs,jsx"),
|
||||||
"src/components/**/*.{js,mjs,cjs,jsx}",
|
ignores,
|
||||||
"src/pages/**/*.{js,mjs,cjs,jsx}",
|
|
||||||
"src/layouts/**/*.{js,mjs,cjs,jsx}",
|
|
||||||
"src/hooks/**/*.{js,mjs,cjs,jsx}",
|
|
||||||
],
|
|
||||||
ignores: ["src/lib/**/*", "src/components/ui/**/*"],
|
|
||||||
...pluginJs.configs.recommended,
|
...pluginJs.configs.recommended,
|
||||||
...pluginReact.configs.flat.recommended,
|
...pluginReact.configs.flat.recommended,
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
@@ -20,42 +72,50 @@ export default [
|
|||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: 2022,
|
ecmaVersion: 2022,
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
ecmaFeatures: {
|
ecmaFeatures: { jsx: true },
|
||||||
jsx: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings,
|
||||||
react: {
|
plugins,
|
||||||
version: "detect",
|
rules,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same block for TypeScript, with the TypeScript parser.
|
||||||
|
*
|
||||||
|
* A separate block rather than one widened glob, so that nothing about how
|
||||||
|
* the existing JavaScript is parsed or reported changes on the day this
|
||||||
|
* lands. `typescript-eslint`'s parser accepts plain JavaScript too, and
|
||||||
|
* merging the two would have been shorter — but it would also have quietly
|
||||||
|
* re-parsed 200-odd existing files, and this phase is meant to be provably
|
||||||
|
* inert.
|
||||||
|
*
|
||||||
|
* Deliberately NOT type-aware (no `projectService`): type errors are
|
||||||
|
* `npm run typecheck`'s job, and asking ESLint to build a program as well
|
||||||
|
* would make every lint run pay for it twice.
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
files: filesWith("ts,tsx,mts,cts"),
|
||||||
|
ignores,
|
||||||
|
...pluginReact.configs.flat.recommended,
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
parser: tseslint.parser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: "module",
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
settings,
|
||||||
react: pluginReact,
|
plugins: { ...plugins, "@typescript-eslint": tseslint.plugin },
|
||||||
"react-hooks": pluginReactHooks,
|
|
||||||
"unused-imports": pluginUnusedImports,
|
|
||||||
},
|
|
||||||
rules: {
|
rules: {
|
||||||
"no-unused-vars": "off",
|
...rules,
|
||||||
"react/jsx-uses-vars": "error",
|
/* TypeScript resolves identifiers itself and reports the ones it cannot,
|
||||||
"react/jsx-uses-react": "error",
|
with better messages and without ESLint's browser/node globals list
|
||||||
"unused-imports/no-unused-imports": "error",
|
needing to be right. Leaving the core rule on would report every `type`
|
||||||
"unused-imports/no-unused-vars": [
|
and `interface` name as undefined. */
|
||||||
"warn",
|
"no-undef": "off",
|
||||||
{
|
|
||||||
vars: "all",
|
|
||||||
varsIgnorePattern: "^_",
|
|
||||||
args: "after-used",
|
|
||||||
argsIgnorePattern: "^_",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"react/prop-types": "off",
|
|
||||||
"react/react-in-jsx-scope": "off",
|
|
||||||
"react/no-unknown-property": [
|
|
||||||
"error",
|
|
||||||
{ ignore: ["cmdk-input-wrapper", "toast-close"] },
|
|
||||||
],
|
|
||||||
"react-hooks/rules-of-hooks": "error",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
},
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"lib": ["esnext", "dom"],
|
|
||||||
"target": "esnext",
|
|
||||||
"checkJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"types": []
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist", "src/components/ui", "src/api", "src/lib"]
|
|
||||||
}
|
|
||||||
1285
package-lock.json
generated
1285
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -9,10 +9,11 @@
|
|||||||
"lint": "eslint . --quiet",
|
"lint": "eslint . --quiet",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "node scripts/skill-check.mjs",
|
"test": "node scripts/skill-check.mjs",
|
||||||
"seed:fixture": "node scripts/seed-fixture.mjs --write",
|
"seed:fixture": "node scripts/seed-fixture.mjs --write",
|
||||||
"seed:check": "node scripts/seed-fixture.mjs",
|
"seed:check": "node scripts/seed-fixture.mjs",
|
||||||
"typecheck": "tsc -p ./jsconfig.json",
|
"typecheck": "tsc -p ./tsconfig.json",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:browser": "node scripts/browser-check.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astryxdesign/core": "^0.3.0",
|
"@astryxdesign/core": "^0.3.0",
|
||||||
@@ -61,8 +62,10 @@
|
|||||||
"eslint-plugin-unused-imports": "^4.3.0",
|
"eslint-plugin-unused-imports": "^4.3.0",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
|
"puppeteer-core": "^23.11.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
|
"typescript-eslint": "^8.70.0",
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,79 @@ agent layer existed. `skill-check.mjs` asserts against it on every run.
|
|||||||
|
|
||||||
Regenerating it is a deliberate act, and the reason belongs here.
|
Regenerating it is a deliberate act, and the reason belongs here.
|
||||||
|
|
||||||
|
## 2026-09-20 — the three HTML baselines that held invented people were recaptured
|
||||||
|
|
||||||
|
Settled. The section that stood here said six checks failed on purpose and
|
||||||
|
listed the evidence; this is that debt being paid.
|
||||||
|
|
||||||
|
`hiringRecords.js` used to pad the hires list with five invented people
|
||||||
|
(`DEMO_FILL`) so Hired History read as a history rather than as three rows. The
|
||||||
|
padding reached Analytics too, where "total hires" counted eight against a
|
||||||
|
database holding three. It was removed, and because these baselines render with
|
||||||
|
queries disabled, the padding was *all* they contained. Candidates separately
|
||||||
|
became the final-selection queue, which changed what its empty state says.
|
||||||
|
|
||||||
|
Verified before regenerating, so the drift was known rather than assumed. Tag
|
||||||
|
counts for Hired History, baseline -> now:
|
||||||
|
|
||||||
|
<td 40 -> 0 five table rows of people who were never hired
|
||||||
|
<span 51 -> 13
|
||||||
|
<div 78 -> 39
|
||||||
|
<p 26 -> 7
|
||||||
|
<svg 20 -> 8
|
||||||
|
|
||||||
|
Every difference was content that had been fabricated. Analytics lost 269 class
|
||||||
|
attributes and 238 words to the same cause. Candidates gained exactly one class
|
||||||
|
— the description paragraph under "Nobody is awaiting a decision". No styling
|
||||||
|
rule, ordering rule or structural rule changed on any of the three.
|
||||||
|
|
||||||
|
**Recaptured and renamed**, because a file called `pre-migration` that holds
|
||||||
|
post-`dca1842` markup is a lie in the filename:
|
||||||
|
|
||||||
|
hired-history.pre-migration.html -> hired-history.render.html
|
||||||
|
analytics.pre-migration.html -> analytics.render.html
|
||||||
|
candidates.pre-migration.html -> candidates.render.html
|
||||||
|
|
||||||
|
`activity-page`, `candidates-analysis`, `control-center`, `positions` and
|
||||||
|
`talent-pool` still hold genuine pre-migration markup, still pass, and keep the
|
||||||
|
name that says so. The `PAGES` table in `skill-check.mjs` now carries each
|
||||||
|
baseline's FILENAME rather than deriving one suffix for all of them.
|
||||||
|
|
||||||
|
**The migration proof was not thrown away with the baseline.** One check —
|
||||||
|
`Hired History added only identity wrappers` — compared tag tallies to assert
|
||||||
|
that migrating the page added exactly two `<div>`s and changed nothing else.
|
||||||
|
That was true, and it was checkable only while the DATA was frozen as well: the
|
||||||
|
predicate subtracts one whole render from another, so removing the invented
|
||||||
|
hires moved every count and the arithmetic stopped describing wrappers at all.
|
||||||
|
Recapturing would not have rescued it — with the baseline equal to the render
|
||||||
|
the delta is zero, and a predicate demanding two can never hold again. Left in
|
||||||
|
place it would have stayed red for a new reason, which is worse than failing
|
||||||
|
for the old one.
|
||||||
|
|
||||||
|
It is replaced by three checks that read the render itself and need no frozen
|
||||||
|
file, so they keep holding as the page's content changes:
|
||||||
|
|
||||||
|
Hired History wraps exactly the node types registered to wrap
|
||||||
|
Hired History identities are unique and name composed nodes
|
||||||
|
Hired History identity wrappers carry no styling
|
||||||
|
|
||||||
|
Together these say what the tally said — `UiTreeRenderer` encloses a type
|
||||||
|
registered `wrap: true` in `<div {...attrs}>`, every other type takes the
|
||||||
|
attributes on its own root element, and a wrapper contributes identity and no
|
||||||
|
styling. Hired History composes five nodes; two are registered to wrap and
|
||||||
|
produce the two divs, two carry their identity on their own `<section>`, and
|
||||||
|
`hired-extensions-top` is an extension slot that renders nothing while no skill
|
||||||
|
is attached to it. The second check therefore asserts uniqueness and no strays
|
||||||
|
rather than one identity per composed node — a one-to-one rule would be
|
||||||
|
asserting that every extension slot is always filled.
|
||||||
|
|
||||||
|
`carries node identity in the DOM` was described here as needing pre-migration
|
||||||
|
markup. It does not: it reads only the live render, names `chronology` and
|
||||||
|
`records` directly, and is unaffected by any of this. It still passes.
|
||||||
|
|
||||||
|
`npm test` is 1693/1693.
|
||||||
|
|
||||||
|
|
||||||
## 2026-08-27 — the seed gained the three statuses nothing exercised
|
## 2026-08-27 — the seed gained the three statuses nothing exercised
|
||||||
|
|
||||||
`application_status` has seven values. The fixture produced four: `applied`,
|
`application_status` has seven values. The fixture produced four: `applied`,
|
||||||
@@ -63,3 +136,48 @@ with it. It now names the page's topics in a sentence.
|
|||||||
Every intent `kind` was unchanged. No routing moved, no skill matching changed.
|
Every intent `kind` was unchanged. No routing moved, no skill matching changed.
|
||||||
That is why this regeneration was safe: the diff was read first, and it was one
|
That is why this regeneration was safe: the diff was read first, and it was one
|
||||||
cosmetic change on a path that only runs when no agent is configured at all.
|
cosmetic change on a path that only runs when no agent is configured at all.
|
||||||
|
|
||||||
|
## 2026-09-11 — Candidates became the final-selection queue
|
||||||
|
|
||||||
|
`candidates: shows the same words` now fails, and it is the only check this
|
||||||
|
change breaks.
|
||||||
|
|
||||||
|
The page used to list every application the org had ever taken — all seven
|
||||||
|
statuses at once — which made it a second Talent Pool rather than the queue of
|
||||||
|
hiring decisions waiting on a human. It now opens on final selection: interview
|
||||||
|
completed, not hired, not rejected. That is a change to what the page *says*, so
|
||||||
|
a check asserting the page says exactly what it said before was always going to
|
||||||
|
fail. There is no version of this work that leaves those words alone.
|
||||||
|
|
||||||
|
**What drifted, verified before leaving it failing.** The rendered delta is the
|
||||||
|
empty state and nothing else:
|
||||||
|
|
||||||
|
before "…0 of 0 candidates No candidates match your filters"
|
||||||
|
now "…0 of 0 candidates Nobody is awaiting a decision
|
||||||
|
Candidates arrive here once their interview is completed,
|
||||||
|
and leave once they are hired or declined."
|
||||||
|
|
||||||
|
Title, subtitle and toolbar meta are byte-identical. The old copy was not merely
|
||||||
|
different, it was untrue: with no filters applied there is nothing to clear, and
|
||||||
|
"no candidates match your filters" describes a filter that was never set.
|
||||||
|
|
||||||
|
Tag and class counts, baseline → now:
|
||||||
|
|
||||||
|
class="…" 26 -> 27 one inserted: the description paragraph
|
||||||
|
|
||||||
|
One insertion, nothing changed and nothing dropped — which is why
|
||||||
|
`candidates: paints the same styled elements, in the same order` and
|
||||||
|
`candidates: carries node identity in the DOM` both still pass. Those two are
|
||||||
|
the migration proof; only the words moved.
|
||||||
|
|
||||||
|
The new stage filter options cost nothing here. `FilterSelect` is a Radix
|
||||||
|
`Select`, so its options live in a portal that is closed in static markup, and
|
||||||
|
`SelectValue` renders empty on the server — the baseline contains neither the
|
||||||
|
old option labels nor the new ones.
|
||||||
|
|
||||||
|
**Regenerated on 2026-09-20**, with the other two — see the entry at the top of
|
||||||
|
this file. It was held until then because `candidates.pre-migration.html` was
|
||||||
|
load-bearing for the UI node tree proof, and recapturing it earlier would have
|
||||||
|
written post-migration markup into a file named `pre-migration`. The file is now
|
||||||
|
`candidates.render.html` and the proof it was holding up has been replaced by
|
||||||
|
three checks that read the render directly.
|
||||||
|
|||||||
1
scripts/__baseline__/activity-page.pre-migration.html
Normal file
1
scripts/__baseline__/activity-page.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/analytics.render.html
Normal file
1
scripts/__baseline__/analytics.render.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/candidates.render.html
Normal file
1
scripts/__baseline__/candidates.render.html
Normal file
File diff suppressed because one or more lines are too long
5
scripts/__baseline__/control-center.pre-migration.html
Normal file
5
scripts/__baseline__/control-center.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/hired-history.render.html
Normal file
1
scripts/__baseline__/hired-history.render.html
Normal file
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@
|
|||||||
"bartending-training",
|
"bartending-training",
|
||||||
"candidate-analysis",
|
"candidate-analysis",
|
||||||
"candidate-search",
|
"candidate-search",
|
||||||
|
"create-employee-role",
|
||||||
"create-position",
|
"create-position",
|
||||||
"customer-service-training",
|
"customer-service-training",
|
||||||
"executive-summary",
|
"executive-summary",
|
||||||
@@ -161,6 +162,7 @@
|
|||||||
"pageKey": "positions",
|
"pageKey": "positions",
|
||||||
"route": "/admin/positions",
|
"route": "/admin/positions",
|
||||||
"skills": [
|
"skills": [
|
||||||
|
"create-employee-role",
|
||||||
"create-position",
|
"create-position",
|
||||||
"hiring-activity-assistant",
|
"hiring-activity-assistant",
|
||||||
"staffing-risk"
|
"staffing-risk"
|
||||||
@@ -707,6 +709,7 @@
|
|||||||
"pageKey": "talent-pool",
|
"pageKey": "talent-pool",
|
||||||
"route": "/admin/talent-pool",
|
"route": "/admin/talent-pool",
|
||||||
"skills": [
|
"skills": [
|
||||||
|
"create-employee-role",
|
||||||
"talent-pool-analysis"
|
"talent-pool-analysis"
|
||||||
],
|
],
|
||||||
"suggestions": [
|
"suggestions": [
|
||||||
|
|||||||
1
scripts/__baseline__/positions.pre-migration.html
Normal file
1
scripts/__baseline__/positions.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/talent-pool.pre-migration.html
Normal file
1
scripts/__baseline__/talent-pool.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
134
scripts/authreturnto-check.mjs
Normal file
134
scripts/authreturnto-check.mjs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* returnTo validation check.
|
||||||
|
*
|
||||||
|
* Runs the REAL module through Vite's SSR loader, the same way
|
||||||
|
* skill-check.mjs does, so the `@/` alias and the TypeScript compile are the
|
||||||
|
* app's own rather than a reimplementation of them. No test framework is added
|
||||||
|
* for a 130-line module; this follows the convention already in this directory.
|
||||||
|
*
|
||||||
|
* node scripts/authreturnto-check.mjs
|
||||||
|
*
|
||||||
|
* Exits non-zero on failure, so it can gate a build.
|
||||||
|
*
|
||||||
|
* WHAT THIS IS DEFENDING
|
||||||
|
*
|
||||||
|
* safeReturnTo decides whether a URL somebody else supplied may be navigated
|
||||||
|
* to. The cases below are therefore mostly hostile input, and each asserts the
|
||||||
|
* result is null rather than merely "not the attacker's value" — a wrong answer
|
||||||
|
* that is still a navigation is not a pass.
|
||||||
|
*/
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const record = (name, pass, detail = '') => {
|
||||||
|
results.push({ name, pass, detail });
|
||||||
|
console.log(`[${pass ? ' ok ' : ' FAIL '}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ORIGIN = 'https://platform.krowforce.com';
|
||||||
|
|
||||||
|
// A real authorization URL, with every parameter the flow depends on, built the
|
||||||
|
// way the Go server builds it: path + RawQuery, percent-escaped into ?returnTo=.
|
||||||
|
const AUTHORIZE =
|
||||||
|
'/oauth/authorize?client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22' +
|
||||||
|
'&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback' +
|
||||||
|
'&response_type=code' +
|
||||||
|
'&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' +
|
||||||
|
'&code_challenge_method=S256' +
|
||||||
|
'&resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp' +
|
||||||
|
'&scope=krow.read' +
|
||||||
|
'&state=vT7nQ2xK_Lp9';
|
||||||
|
|
||||||
|
const q = (v) => '?returnTo=' + encodeURIComponent(v);
|
||||||
|
|
||||||
|
const server = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' });
|
||||||
|
try {
|
||||||
|
globalThis.window = { location: { origin: ORIGIN, search: '' } };
|
||||||
|
const { safeReturnTo } = await server.ssrLoadModule('/src/lib/authReturnTo.ts');
|
||||||
|
|
||||||
|
/* ── 1–2. The OAuth authorize URL, and its query byte for byte ─────────── */
|
||||||
|
|
||||||
|
const oauth = safeReturnTo(q(AUTHORIZE));
|
||||||
|
record('1. /oauth/authorize is accepted', oauth !== null && oauth.path === AUTHORIZE,
|
||||||
|
oauth ? `via=${oauth.via}` : 'returned null');
|
||||||
|
record('12. and is marked for full browser navigation', oauth?.via === 'browser',
|
||||||
|
`via=${oauth?.via} — React Router has no such route`);
|
||||||
|
|
||||||
|
for (const [name, literal] of [
|
||||||
|
['client_id', 'client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22'],
|
||||||
|
['redirect_uri', 'redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback'],
|
||||||
|
['response_type', 'response_type=code'],
|
||||||
|
['code_challenge', 'code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'],
|
||||||
|
['code_challenge_method', 'code_challenge_method=S256'],
|
||||||
|
['resource', 'resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp'],
|
||||||
|
['scope', 'scope=krow.read'],
|
||||||
|
['state', 'state=vT7nQ2xK_Lp9'],
|
||||||
|
]) {
|
||||||
|
record(`2. ${name} preserved exactly`, Boolean(oauth?.path.includes(literal)));
|
||||||
|
}
|
||||||
|
record('2. percent-encoding is not rewritten', Boolean(oauth?.path.includes('%2F')),
|
||||||
|
'%2F must not become /');
|
||||||
|
record('2. an encoded space survives',
|
||||||
|
safeReturnTo(q('/oauth/authorize?scope=krow.read%20krow.write'))?.path.includes('%20') === true,
|
||||||
|
'%20 must not become +');
|
||||||
|
|
||||||
|
/* ── 3, 13. Internal admin routes keep router navigation ───────────────── */
|
||||||
|
|
||||||
|
const admin = safeReturnTo(q('/admin/candidates?stage=applied'));
|
||||||
|
record('3. /admin/... is accepted', admin?.path === '/admin/candidates?stage=applied');
|
||||||
|
record('13. and is marked for router navigation', admin?.via === 'router',
|
||||||
|
`via=${admin?.via} — must not reload the app`);
|
||||||
|
record('3. bare /admin is accepted', safeReturnTo(q('/admin'))?.via === 'router');
|
||||||
|
|
||||||
|
/* ── 4–10. Hostile and malformed values are refused ────────────────────── */
|
||||||
|
|
||||||
|
const refuse = [
|
||||||
|
['4. external URL', 'https://evil.example'],
|
||||||
|
['4. external URL with our path', 'https://evil.example/oauth/authorize'],
|
||||||
|
['4. userinfo trick', 'https://platform.krowforce.com@evil.example/'],
|
||||||
|
['4. another port on our host', 'https://platform.krowforce.com:8443/admin'],
|
||||||
|
['5. protocol-relative', '//evil.example'],
|
||||||
|
['5. protocol-relative with path', '//evil.example/steal'],
|
||||||
|
['5. triple slash', '///evil.example'],
|
||||||
|
['6. javascript:', 'javascript:alert(document.cookie)'],
|
||||||
|
['6. javascript: mixed case', 'JaVaScRiPt:alert(1)'],
|
||||||
|
['6. tab-obfuscated scheme', 'java\tscript:alert(1)'],
|
||||||
|
['7. data:', 'data:text/html,<script>alert(1)</script>'],
|
||||||
|
['8. backslash', '/\\evil.example'],
|
||||||
|
// These two reach the slash/backslash guard specifically: the path is on
|
||||||
|
// the allowlist, so only the guard can refuse them. Without them the guard
|
||||||
|
// is unfalsifiable — removing it leaves every other case still passing,
|
||||||
|
// which a mutation run showed.
|
||||||
|
['8. backslash in the query of an allowed path', '/admin/candidates?a=\\evil'],
|
||||||
|
['8. backslash escape smuggled past the allowlist', '/admin/x?next=/\\evil.example'],
|
||||||
|
['8. dot-slash-slash', '/.//evil.example'],
|
||||||
|
['9. /admin/login itself', '/admin/login'],
|
||||||
|
['9. /admin/login with a query', '/admin/login?returnTo=%2Fadmin'],
|
||||||
|
['9. bare /login', '/login'],
|
||||||
|
['10. malformed', 'http://[::1'],
|
||||||
|
['10. file scheme', 'file:///etc/passwd'],
|
||||||
|
['10. unrelated backend route', '/oauth/token'],
|
||||||
|
['10. unrelated app route', '/apply'],
|
||||||
|
['10. the MCP endpoint', '/mcp'],
|
||||||
|
];
|
||||||
|
for (const [name, value] of refuse) {
|
||||||
|
const got = safeReturnTo(q(value));
|
||||||
|
record(`${name} is refused`, got === null, got ? `returned ${JSON.stringify(got)}` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 11. Absent or empty falls back safely ─────────────────────────────── */
|
||||||
|
|
||||||
|
for (const [name, search] of [
|
||||||
|
['no query at all', ''],
|
||||||
|
['other parameters only', '?foo=bar'],
|
||||||
|
['empty returnTo', '?returnTo='],
|
||||||
|
]) {
|
||||||
|
record(`11. ${name} returns null`, safeReturnTo(search) === null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = results.filter((r) => !r.pass).length;
|
||||||
|
console.log(`\n${results.length - failed} passed, ${failed} failed\n`);
|
||||||
|
process.exit(failed === 0 ? 0 : 1);
|
||||||
166
scripts/browser-check.mjs
Normal file
166
scripts/browser-check.mjs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* The live acceptance suite: the Owliver UI-editing journey, in a real browser.
|
||||||
|
*
|
||||||
|
* npm run test:browser
|
||||||
|
*
|
||||||
|
* Unit and SSR tests render these same modules and were not enough — every
|
||||||
|
* defect this guards was found in a browser and missed by a green test run. So
|
||||||
|
* this drives the real application: real routing, real React, real network,
|
||||||
|
* real `preferences.uiLayouts`.
|
||||||
|
*
|
||||||
|
* **It never logs in.** The session is an HttpOnly cookie and this script has
|
||||||
|
* no business handling anybody's credentials, so it attaches to a browser that
|
||||||
|
* is *already* signed in and says so plainly when it cannot:
|
||||||
|
*
|
||||||
|
* KROW_E2E_BROWSER_URL=http://127.0.0.1:9222 attach to a running Chrome
|
||||||
|
* (start one with: --remote-debugging-port=9222)
|
||||||
|
*
|
||||||
|
* KROW_E2E_USER_DATA_DIR=/path/to/profile launch Chrome on a profile
|
||||||
|
* that has been signed in once by hand
|
||||||
|
*
|
||||||
|
* With neither set, or with the browser not signed in, it exits **BLOCKED** —
|
||||||
|
* never a silent pass. An acceptance suite that reports green because it never
|
||||||
|
* ran is worse than no suite.
|
||||||
|
*
|
||||||
|
* The journey spans reloads, so the phases live in `browser-flows.js` and the
|
||||||
|
* reloads live here.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import puppeteer from 'puppeteer-core';
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
const BASE = process.env.KROW_E2E_BASE_URL || 'http://localhost:5173';
|
||||||
|
const FLOWS = readFileSync(join(ROOT, 'scripts/browser-flows.js'), 'utf8');
|
||||||
|
const CHROME = process.env.KROW_E2E_CHROME
|
||||||
|
|| '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pages under test, as data.
|
||||||
|
*
|
||||||
|
* `target` is a node the page really composes and `phrase` is how a person
|
||||||
|
* names it — the pair this suite drives every page with. Adding a page is a row.
|
||||||
|
*/
|
||||||
|
const PAGES = [
|
||||||
|
{ page: 'activity', route: '/admin/activity', target: 'audit', phrase: 'the audit log' },
|
||||||
|
{ page: 'hired-history', route: '/admin/hired', target: 'chronology', phrase: 'the recent hiring timeline' },
|
||||||
|
{ page: 'analytics', route: '/admin/analytics', target: 'funnel', phrase: 'the hiring funnel' },
|
||||||
|
{
|
||||||
|
page: 'positions', route: '/admin/positions', target: 'skill-board-board', phrase: 'the Board card',
|
||||||
|
/* The Board skill's own card: a *skill* node, so the same journey proves
|
||||||
|
built-in and definition-contributed UI move through one engine. Needs the
|
||||||
|
Board definition switched on, which `prepare` below does not do — the
|
||||||
|
page is skipped rather than failed when its node is not there. */
|
||||||
|
optional: true,
|
||||||
|
data: ['What is on the board?', 'Show me the board activity'],
|
||||||
|
ordinaryQuestion: 'How do I apply for this position?',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const record = (name, pass, detail) => results.push({ name, pass, detail: detail || '' });
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (process.env.KROW_E2E_BROWSER_URL) {
|
||||||
|
return puppeteer.connect({ browserURL: process.env.KROW_E2E_BROWSER_URL, defaultViewport: null });
|
||||||
|
}
|
||||||
|
if (process.env.KROW_E2E_USER_DATA_DIR) {
|
||||||
|
return puppeteer.launch({
|
||||||
|
executablePath: CHROME,
|
||||||
|
userDataDir: process.env.KROW_E2E_USER_DATA_DIR,
|
||||||
|
headless: false,
|
||||||
|
defaultViewport: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load a route, install the flows, and wait for the page to have composed. */
|
||||||
|
async function open(page, route) {
|
||||||
|
await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle2', timeout: 45_000 });
|
||||||
|
await page.evaluate(FLOWS);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector('textarea') && document.querySelectorAll('[data-ui-node]').length >= 0,
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 2500));
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = (page, fn, args) => page.evaluate(
|
||||||
|
async (name, a) => window.__uiFlows[name](a), fn, args
|
||||||
|
);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await connect();
|
||||||
|
if (!browser) {
|
||||||
|
console.error(
|
||||||
|
'BLOCKED — no browser to attach to.\n'
|
||||||
|
+ ' This suite does not log in: the session is an HttpOnly cookie and this\n'
|
||||||
|
+ ' script does not handle credentials. Point it at a signed-in browser:\n\n'
|
||||||
|
+ ' KROW_E2E_BROWSER_URL=http://127.0.0.1:9222 npm run test:browser\n'
|
||||||
|
+ ' (start Chrome with --remote-debugging-port=9222)\n\n'
|
||||||
|
+ ' KROW_E2E_USER_DATA_DIR=/path/to/profile npm run test:browser\n'
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await browser.newPage();
|
||||||
|
try {
|
||||||
|
await open(page, PAGES[0].route);
|
||||||
|
|
||||||
|
/* Signed in, or nothing below means anything. */
|
||||||
|
const authed = await page.evaluate(async () => {
|
||||||
|
const r = await fetch('/api/v1/me/preferences', { credentials: 'include' });
|
||||||
|
return { status: r.status, composer: Boolean(document.querySelector('textarea')) };
|
||||||
|
});
|
||||||
|
if (authed.status !== 200 || !authed.composer) {
|
||||||
|
console.error(
|
||||||
|
`BLOCKED — that browser is not signed in (GET /me/preferences → ${authed.status}).\n`
|
||||||
|
+ ' Sign in once in that profile, then run this again.'
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const spec of PAGES) {
|
||||||
|
await open(page, spec.route);
|
||||||
|
|
||||||
|
const present = await page.evaluate((t) => window.__uiFlows.nodes().includes(t), spec.target);
|
||||||
|
if (!present) {
|
||||||
|
if (spec.optional) {
|
||||||
|
record(`${spec.page}: SKIPPED — ${spec.target} is not on the page`, true,
|
||||||
|
'the definition contributing it is switched off');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
record(`${spec.page}: ${spec.target} is on the page`, false, 'not composed');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(...await run(page, 'nothingPreviewed', {
|
||||||
|
page: spec.page, ordinaryQuestion: spec.ordinaryQuestion || null,
|
||||||
|
}));
|
||||||
|
if (spec.data) results.push(...await run(page, 'dataStaysData', { page: spec.page, questions: spec.data }));
|
||||||
|
|
||||||
|
results.push(...await run(page, 'phase1', spec));
|
||||||
|
await open(page, spec.route);
|
||||||
|
results.push(...await run(page, 'phase2', spec));
|
||||||
|
await open(page, spec.route);
|
||||||
|
results.push(...await run(page, 'phase3', spec));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (process.env.KROW_E2E_BROWSER_URL) browser.disconnect();
|
||||||
|
else await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = results.filter((r) => !r.pass);
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(`[ ${r.pass ? ' ok ' : 'FAIL'} ] ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
|
||||||
|
}
|
||||||
|
console.log(`\n${results.length - failed.length}/${results.length} browser checks passed`);
|
||||||
|
if (failed.length) {
|
||||||
|
console.log('\nFailed:');
|
||||||
|
for (const r of failed) console.log(` - ${r.name} (${r.detail})`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => { console.error('BROWSER SUITE ERROR:', error); process.exit(1); });
|
||||||
256
scripts/browser-flows.js
Normal file
256
scripts/browser-flows.js
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* The Owliver UI-editing journey, as a script that runs *inside a real page*.
|
||||||
|
*
|
||||||
|
* Unit and SSR tests render the same modules this file drives, and they were
|
||||||
|
* not enough: every defect this suite now guards was found in a browser, not in
|
||||||
|
* a test run. So the acceptance test is the browser, and this is what it runs.
|
||||||
|
*
|
||||||
|
* It is deliberately dependency-free and framework-free — one function, no
|
||||||
|
* imports, no build step — because it has to be evaluatable in any authenticated
|
||||||
|
* session: through the puppeteer driver beside it, or pasted into a console.
|
||||||
|
*
|
||||||
|
* A full journey spans reloads, and nothing in a page survives one. So the
|
||||||
|
* journey is split into **phases** and the *driver* owns the reloads:
|
||||||
|
*
|
||||||
|
* phase 1 inspect → hide → preview → discard → hide → Apply
|
||||||
|
* ── reload ──
|
||||||
|
* phase 2 verify hidden → hidden node still listed → unhide → Apply
|
||||||
|
* ── reload ──
|
||||||
|
* phase 3 verify restored
|
||||||
|
*
|
||||||
|
* Every assertion is about what the browser actually shows — the composed
|
||||||
|
* nodes in the document and the bytes in `preferences.uiLayouts` — never about
|
||||||
|
* a module's return value.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* global window, document, fetch, HTMLTextAreaElement, Event */
|
||||||
|
|
||||||
|
(function attach() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
/** The nodes the page is actually drawing, in document order. */
|
||||||
|
const nodes = () => [...document.querySelectorAll('[data-ui-node]')]
|
||||||
|
.map((el) => el.getAttribute('data-ui-node'));
|
||||||
|
|
||||||
|
/** What the panel is showing. The transcript, not a component's state. */
|
||||||
|
const panel = () => document.querySelector('[class*="assistant"], aside')?.innerText || '';
|
||||||
|
|
||||||
|
/** The saved layouts, read back over the wire like any other client would. */
|
||||||
|
const saved = async () => {
|
||||||
|
const response = await fetch('/api/v1/me/preferences', { credentials: 'include' });
|
||||||
|
const body = await response.json();
|
||||||
|
const prefs = (body.data || body).preferences || (body.data || body);
|
||||||
|
return { uiLayouts: prefs.uiLayouts || {}, disabledSkills: prefs.disabledSkills || [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => { window.setTimeout(resolve, ms); });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until the panel is genuinely idle.
|
||||||
|
*
|
||||||
|
* Not cosmetic: a message submitted while the panel is streaming is dropped,
|
||||||
|
* and a suite that does not wait reports a phantom failure for a request that
|
||||||
|
* was never sent. That is exactly what happened during the manual audit.
|
||||||
|
*/
|
||||||
|
const idle = async () => {
|
||||||
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||||
|
const box = document.querySelector('textarea');
|
||||||
|
if (box && !box.disabled && !/Thinking/.test(document.body.innerText)) return true;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask Owliver, the way a person does.
|
||||||
|
*
|
||||||
|
* Through the composer and the form's own submit — not by calling a handler —
|
||||||
|
* so the routing, the gate and the panel are all really exercised.
|
||||||
|
*/
|
||||||
|
const ask = async (question) => {
|
||||||
|
await idle();
|
||||||
|
const box = document.querySelector('textarea');
|
||||||
|
const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
|
||||||
|
setValue.call(box, question);
|
||||||
|
box.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await sleep(150);
|
||||||
|
box.closest('form').requestSubmit();
|
||||||
|
await sleep(1500);
|
||||||
|
await idle();
|
||||||
|
await sleep(600);
|
||||||
|
return panel();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every request that left the page, so "did this reach the model?" is
|
||||||
|
* answered by the network rather than by reading the reply and guessing.
|
||||||
|
*/
|
||||||
|
const spy = () => {
|
||||||
|
if (window.__uiFlowSpy) { window.__uiFlowCalls = []; return; }
|
||||||
|
window.__uiFlowCalls = [];
|
||||||
|
const original = window.fetch;
|
||||||
|
window.fetch = function spied(...args) {
|
||||||
|
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
|
||||||
|
const method = (args[1]?.method || 'GET').toUpperCase();
|
||||||
|
if (/\/api\/v1\/(agents|runs)/.test(url) || method !== 'GET') {
|
||||||
|
window.__uiFlowCalls.push(`${method} ${url}`);
|
||||||
|
}
|
||||||
|
return original.apply(this, args);
|
||||||
|
};
|
||||||
|
window.__uiFlowSpy = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const modelCalls = () => (window.__uiFlowCalls || []).filter((c) => /\/agents\//.test(c));
|
||||||
|
const writeCalls = () => (window.__uiFlowCalls || []).filter((c) => /^PATCH/.test(c));
|
||||||
|
|
||||||
|
/** One assertion. `detail` is what a reader needs to debug a failure. */
|
||||||
|
const check = (results, name, pass, detail) => {
|
||||||
|
results.push({ name, pass: Boolean(pass), detail: String(detail ?? '') });
|
||||||
|
return Boolean(pass);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1 — the whole preview contract, before anything is kept.
|
||||||
|
*
|
||||||
|
* `target` is a node id; `phrase` is how a person would name it. Both are
|
||||||
|
* given by the caller so this file names no page and no section.
|
||||||
|
*/
|
||||||
|
async function phase1({ page, target, phrase }) {
|
||||||
|
const results = [];
|
||||||
|
spy();
|
||||||
|
|
||||||
|
const inventory = await ask('What is on this page?');
|
||||||
|
check(results, `${page}: inspect answers locally`, modelCalls().length === 0,
|
||||||
|
modelCalls().join(', ') || 'no agent run');
|
||||||
|
check(results, `${page}: inspect lists ${target}`, inventory.includes(`(${target})`),
|
||||||
|
inventory.slice(-300));
|
||||||
|
|
||||||
|
const before = nodes();
|
||||||
|
check(results, `${page}: ${target} is on the page`, before.includes(target), before.join(', '));
|
||||||
|
|
||||||
|
/* What was stored before anything was previewed. Compared against rather
|
||||||
|
than assumed empty: a page that has been customised before still has a
|
||||||
|
patch, and the claim under test is that a *preview* does not change it. */
|
||||||
|
const storedBefore = JSON.stringify((await saved()).uiLayouts[page] ?? null);
|
||||||
|
|
||||||
|
window.__uiFlowCalls = [];
|
||||||
|
await ask(`Hide ${phrase}`);
|
||||||
|
const hidden = nodes();
|
||||||
|
check(results, `${page}: hide removes it from the page`, !hidden.includes(target), hidden.join(', '));
|
||||||
|
check(results, `${page}: hide does not reach the model`, modelCalls().length === 0,
|
||||||
|
modelCalls().join(', ') || 'no agent run');
|
||||||
|
|
||||||
|
const storedDuring = JSON.stringify((await saved()).uiLayouts[page] ?? null);
|
||||||
|
check(results, `${page}: preview writes nothing`,
|
||||||
|
storedDuring === storedBefore && writeCalls().length === 0,
|
||||||
|
`before ${storedBefore} · during ${storedDuring}`);
|
||||||
|
|
||||||
|
await ask('Discard the layout change');
|
||||||
|
check(results, `${page}: discard restores it`, nodes().includes(target), nodes().join(', '));
|
||||||
|
|
||||||
|
await ask(`Hide ${phrase}`);
|
||||||
|
check(results, `${page}: hide again`, !nodes().includes(target), nodes().join(', '));
|
||||||
|
|
||||||
|
window.__uiFlowCalls = [];
|
||||||
|
await ask('Apply the layout change');
|
||||||
|
await sleep(1200);
|
||||||
|
const persisted = await saved();
|
||||||
|
const patch = persisted.uiLayouts[page];
|
||||||
|
check(results, `${page}: apply writes preferences`, writeCalls().length > 0, writeCalls().join(', '));
|
||||||
|
check(results, `${page}: apply stores an operation, not a tree`,
|
||||||
|
Boolean(patch) && patch.ops.some((op) => op.op === 'hide' && op.target === target && op.hidden === true),
|
||||||
|
JSON.stringify(patch ?? null));
|
||||||
|
check(results, `${page}: apply does not reach the model`, modelCalls().length === 0,
|
||||||
|
modelCalls().join(', ') || 'no agent run');
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phase 2 — after a cold reload: still hidden, still addressable, put back. */
|
||||||
|
async function phase2({ page, target, phrase }) {
|
||||||
|
const results = [];
|
||||||
|
spy();
|
||||||
|
|
||||||
|
check(results, `${page}: reload reconstructs the hidden state`, !nodes().includes(target), nodes().join(', '));
|
||||||
|
|
||||||
|
const inventory = await ask('What is on this page?');
|
||||||
|
check(results, `${page}: a hidden node stays in the inventory`, inventory.includes(`(${target})`),
|
||||||
|
inventory.slice(-300));
|
||||||
|
|
||||||
|
window.__uiFlowCalls = [];
|
||||||
|
await ask(`Show ${phrase}`);
|
||||||
|
check(results, `${page}: a hidden node is addressable after a reload`, nodes().includes(target),
|
||||||
|
nodes().join(', '));
|
||||||
|
check(results, `${page}: unhide does not reach the model`, modelCalls().length === 0,
|
||||||
|
modelCalls().join(', ') || 'no agent run');
|
||||||
|
|
||||||
|
await ask('Apply the layout change');
|
||||||
|
await sleep(1200);
|
||||||
|
const patch = (await saved()).uiLayouts[page];
|
||||||
|
check(results, `${page}: the unhide is persisted`,
|
||||||
|
Boolean(patch) && patch.ops.some((op) => op.op === 'hide' && op.target === target && op.hidden === false),
|
||||||
|
JSON.stringify(patch ?? null));
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phase 3 — after a second cold reload, the page is itself again. */
|
||||||
|
async function phase3({ page, target }) {
|
||||||
|
const results = [];
|
||||||
|
check(results, `${page}: reload reconstructs the restored state`, nodes().includes(target), nodes().join(', '));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regression this suite exists for.
|
||||||
|
*
|
||||||
|
* Applying or discarding with nothing previewed used to fall through to the
|
||||||
|
* model, which answered — reasonably, for an agent scoped to open roles —
|
||||||
|
* that layout changes were not in its scope. A request about the interface
|
||||||
|
* must never be answered by something that does not know the interface
|
||||||
|
* exists. The second half is as important: ordinary "apply" must still be an
|
||||||
|
* ordinary word.
|
||||||
|
*/
|
||||||
|
async function nothingPreviewed({ page, ordinaryQuestion }) {
|
||||||
|
const results = [];
|
||||||
|
spy();
|
||||||
|
|
||||||
|
for (const verb of ['Discard', 'Apply']) {
|
||||||
|
window.__uiFlowCalls = [];
|
||||||
|
const reply = await ask(`${verb} the layout change`);
|
||||||
|
check(results, `${page}: "${verb} the layout change" with nothing previewed stays local`,
|
||||||
|
modelCalls().length === 0, modelCalls().join(', ') || 'no agent run');
|
||||||
|
check(results, `${page}: it says there is nothing to ${verb.toLowerCase()}`,
|
||||||
|
new RegExp(`nothing to ${verb.toLowerCase()}`, 'i').test(reply), reply.slice(-240));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ordinaryQuestion) {
|
||||||
|
window.__uiFlowCalls = [];
|
||||||
|
const reply = await ask(ordinaryQuestion);
|
||||||
|
check(results, `${page}: an ordinary "apply" is not swallowed`,
|
||||||
|
modelCalls().length > 0 && !/nothing to apply/i.test(reply.slice(-260)),
|
||||||
|
modelCalls().join(', ') || 'no agent run');
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A data question must stay a data question, however it is worded. */
|
||||||
|
async function dataStaysData({ page, questions }) {
|
||||||
|
const results = [];
|
||||||
|
spy();
|
||||||
|
for (const question of questions) {
|
||||||
|
window.__uiFlowCalls = [];
|
||||||
|
const before = nodes();
|
||||||
|
await ask(question);
|
||||||
|
check(results, `${page}: "${question}" is answered as data`,
|
||||||
|
modelCalls().length > 0
|
||||||
|
&& !/Previewing/.test(document.body.innerText)
|
||||||
|
&& JSON.stringify(before) === JSON.stringify(nodes()),
|
||||||
|
`${modelCalls().join(', ') || 'no agent run'} · nodes ${nodes().join(', ')}`);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__uiFlows = { phase1, phase2, phase3, nothingPreviewed, dataStaysData, nodes, saved, ask, panel };
|
||||||
|
}());
|
||||||
209
scripts/gen-entity-types.mjs
Normal file
209
scripts/gen-entity-types.mjs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Generates `src/types/entities.generated.ts` from the backend's resource
|
||||||
|
* registry.
|
||||||
|
*
|
||||||
|
* node scripts/gen-entity-types.mjs # check: exits non-zero if stale
|
||||||
|
* node scripts/gen-entity-types.mjs --write # regenerate
|
||||||
|
*
|
||||||
|
* The same arrangement as `seed-fixture.mjs`, and for the same reason: two
|
||||||
|
* descriptions of one shape, maintained by hand, only ever fail quietly. Here
|
||||||
|
* the two are the columns Postgres actually has and the fields this app
|
||||||
|
* believes it will be sent.
|
||||||
|
*
|
||||||
|
* ## Why generate rather than transcribe
|
||||||
|
*
|
||||||
|
* `krow-backend/go-api/internal/domain/resources_gen.go` is itself generated,
|
||||||
|
* out of `information_schema`, and says so: "Column names, types, enum values
|
||||||
|
* and nullability are read out of information_schema so they cannot drift from
|
||||||
|
* the migrations." It is the closest thing to the database that can be read
|
||||||
|
* without connecting to one — 15 resources, 298 columns, every one carrying its
|
||||||
|
* kind, its nullability and its permitted values.
|
||||||
|
*
|
||||||
|
* Two hundred and ninety-eight fields retyped by hand would be two hundred and
|
||||||
|
* ninety-eight chances to be confidently wrong, and a type that is wrong is
|
||||||
|
* worse than no type: it is a claim the compiler will defend.
|
||||||
|
*
|
||||||
|
* ## Where the TypeScript types come from
|
||||||
|
*
|
||||||
|
* Not from the Postgres type — from `Column.SelectExpr()` in
|
||||||
|
* `domain/resource.go`, which is what the read projection actually emits. The
|
||||||
|
* two differ, and the differences are the whole point of reading the code
|
||||||
|
* rather than the schema:
|
||||||
|
*
|
||||||
|
* uuid ::text -> string (never a byte array)
|
||||||
|
* numeric ::float8 -> number (never pgtype.Numeric)
|
||||||
|
* date to_char(…,'YYYY-MM-DD') -> string (a date, not a timestamp)
|
||||||
|
* timestamptz to_char(…ISO with ms…) -> string
|
||||||
|
* citext ::text -> string
|
||||||
|
* bigint ::text -> string ← user_activity.id only
|
||||||
|
* int (uncast) -> number
|
||||||
|
*
|
||||||
|
* That last one is the case worth the whole exercise: `user_activity.id` is an
|
||||||
|
* identity bigint and arrives as a STRING, because every id the frontend
|
||||||
|
* handles is an opaque string. A hand-written interface would have called it a
|
||||||
|
* number, and nothing would have contradicted that until a comparison silently
|
||||||
|
* stopped matching.
|
||||||
|
*
|
||||||
|
* Nullability is the column's: `NotNull: true` becomes a required field, its
|
||||||
|
* absence becomes `| null`. Every column is emitted because the projection
|
||||||
|
* emits every column — `Repo.selectList()` maps over `res.Columns` with no
|
||||||
|
* filter, and §4.1 of the API contract states the record is complete.
|
||||||
|
*
|
||||||
|
* ## What this does NOT generate
|
||||||
|
*
|
||||||
|
* Three of the eighteen entities the client knows are absent from the registry
|
||||||
|
* because they are not served by the generic entity machinery: AgentDefinition
|
||||||
|
* and SkillDefinition have dedicated handlers in `httpserver/definitions.go`,
|
||||||
|
* and the user is `httpserver/me.go`. Their shapes are hand-written in
|
||||||
|
* `src/types/user.ts` and `src/types/entities.ts`. This file does not invent
|
||||||
|
* them.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
/** The registry, read from the sibling checkout. */
|
||||||
|
export const REGISTRY_PATH = join(
|
||||||
|
process.cwd(), '..', 'krow-backend', 'go-api', 'internal', 'domain', 'resources_gen.go');
|
||||||
|
|
||||||
|
export const OUTPUT_PATH = join(process.cwd(), 'src', 'types', 'entities.generated.ts');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind -> TypeScript, following `Column.SelectExpr()` rather than the column's
|
||||||
|
* Postgres type. `pgType` is needed because two kinds cast conditionally.
|
||||||
|
*/
|
||||||
|
function tsTypeFor(kind, pgType, enumValues) {
|
||||||
|
switch (kind) {
|
||||||
|
case 'KindUUID': return 'string'; // ::text
|
||||||
|
case 'KindString': return 'string'; // citext also ::text
|
||||||
|
case 'KindBool': return 'boolean';
|
||||||
|
case 'KindFloat': return 'number'; // ::float8
|
||||||
|
case 'KindInt': return pgType === 'bigint' ? 'string' : 'number';
|
||||||
|
case 'KindDate': return 'string'; // 'YYYY-MM-DD'
|
||||||
|
case 'KindTimestamp': return 'string'; // ISO-8601 with ms
|
||||||
|
case 'KindTextArray': return 'string[]';
|
||||||
|
case 'KindEnum': return enumValues.map((v) => `'${v}'`).join(' | ');
|
||||||
|
case 'KindJSON': return 'unknown'; // jsonb: shape is the column's own
|
||||||
|
default: throw new Error(`unmapped Kind: ${kind}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses the Go registry into `[{ name, path, table, columns }]`. */
|
||||||
|
export function parseRegistry(source) {
|
||||||
|
const resources = [];
|
||||||
|
/* Each resource opens with a lone `{` one tab in, then a `Name:` line two
|
||||||
|
tabs in. Splitting on that opener gives one block per resource; the header
|
||||||
|
match rejects anything that is not one. */
|
||||||
|
const blocks = source.split(/\n\t\{\n/).slice(1);
|
||||||
|
for (const block of blocks) {
|
||||||
|
const head = block.match(/^\t\tName: "(\w+)", Path: "([\w-]+)", Table: "(\w+)",/);
|
||||||
|
if (!head) continue;
|
||||||
|
const [, name, path, table] = head;
|
||||||
|
const columns = [];
|
||||||
|
const colRe = /\{Name: "(\w+)", Kind: (Kind\w+), PGType: "([^"]+)"([^}]*)\}/g;
|
||||||
|
let m;
|
||||||
|
while ((m = colRe.exec(block)) !== null) {
|
||||||
|
const [, colName, kind, pgType, rest] = m;
|
||||||
|
/* `rest` is already cut at the first `}`, which for an enum column is
|
||||||
|
the one closing `[]string{...}` — so the values are in it but the
|
||||||
|
brace is not. Requiring a closing brace here matched nothing and
|
||||||
|
silently produced empty unions. */
|
||||||
|
const enumMatch = rest.match(/Enum: \[\]string\{([^}]*)/);
|
||||||
|
const enumValues = enumMatch
|
||||||
|
? [...enumMatch[1].matchAll(/"([^"]*)"/g)].map((e) => e[1])
|
||||||
|
: [];
|
||||||
|
columns.push({
|
||||||
|
name: colName,
|
||||||
|
kind,
|
||||||
|
pgType,
|
||||||
|
notNull: /NotNull: true/.test(rest),
|
||||||
|
readOnly: /ReadOnly: true/.test(rest),
|
||||||
|
required: /Required: true/.test(rest),
|
||||||
|
enumValues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (columns.length) resources.push({ name, path, table, columns });
|
||||||
|
}
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLAGS = (c) => {
|
||||||
|
const notes = [];
|
||||||
|
if (c.readOnly) notes.push('server-owned');
|
||||||
|
if (c.required) notes.push('required on create');
|
||||||
|
return notes.length ? ` /** ${notes.join('; ')}. */\n` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One resource -> one exported interface. */
|
||||||
|
function renderInterface(res) {
|
||||||
|
const fields = res.columns.map((c) => {
|
||||||
|
const ts = tsTypeFor(c.kind, c.pgType, c.enumValues);
|
||||||
|
/* A nullable column returns JSON null, so the field is present and null
|
||||||
|
rather than absent. `?:` would describe a key that can be missing, which
|
||||||
|
is not what the projection does. */
|
||||||
|
const type = c.notNull ? ts : `${ts} | null`;
|
||||||
|
return `${FLAGS(c)} ${c.name}: ${type};`;
|
||||||
|
}).join('\n');
|
||||||
|
return `/** \`${res.path}\` — the \`${res.table}\` table, every column the projection returns. */\nexport interface ${res.name} {\n${fields}\n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render(resources) {
|
||||||
|
const banner = `/**
|
||||||
|
* GENERATED FILE — DO NOT EDIT BY HAND.
|
||||||
|
*
|
||||||
|
* Regenerate with: node scripts/gen-entity-types.mjs --write
|
||||||
|
* Source of truth: krow-backend/go-api/internal/domain/resources_gen.go
|
||||||
|
* (itself generated from information_schema)
|
||||||
|
*
|
||||||
|
* Field types follow \`Column.SelectExpr()\` in \`domain/resource.go\` — what the
|
||||||
|
* read projection emits — not the raw Postgres type. See the generator's header
|
||||||
|
* for the mapping and for why the two differ.
|
||||||
|
*
|
||||||
|
* ${resources.length} resources, ${resources.reduce((n, r) => n + r.columns.length, 0)} columns.
|
||||||
|
*/
|
||||||
|
`;
|
||||||
|
const interfaces = resources.map(renderInterface).join('\n\n');
|
||||||
|
const names = resources.map((r) => ` | '${r.name}'`).join('\n');
|
||||||
|
const mapEntries = resources.map((r) => ` ${r.name}: ${r.name};`).join('\n');
|
||||||
|
const tail = `/** The entity names the generic registry serves. Three more exist — see \`entities.ts\`. */
|
||||||
|
export type GeneratedEntityName =
|
||||||
|
${names};
|
||||||
|
|
||||||
|
/** Entity name -> its record type, for looking a record up by name. */
|
||||||
|
export interface GeneratedEntityRecords {
|
||||||
|
${mapEntries}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
return `${banner}\n${interfaces}\n\n${tail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CLI ──────────────────────────────────────────────────────────────────── */
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
if (!existsSync(REGISTRY_PATH)) {
|
||||||
|
console.log('krow-backend is not checked out beside this repo; entity types not checked here.');
|
||||||
|
console.log('The types in src/types/entities.generated.ts are committed, so this is not fatal.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = parseRegistry(readFileSync(REGISTRY_PATH, 'utf8'));
|
||||||
|
if (!resources.length) {
|
||||||
|
console.error('parsed no resources out of resources_gen.go — has its shape changed?');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const generated = render(resources);
|
||||||
|
|
||||||
|
if (process.argv.includes('--write')) {
|
||||||
|
writeFileSync(OUTPUT_PATH, generated);
|
||||||
|
const cols = resources.reduce((n, r) => n + r.columns.length, 0);
|
||||||
|
console.log(`entities.generated.ts written — ${resources.length} resources, ${cols} columns.`);
|
||||||
|
} else if (!existsSync(OUTPUT_PATH)) {
|
||||||
|
console.error('entities.generated.ts is missing. Run: node scripts/gen-entity-types.mjs --write');
|
||||||
|
process.exit(1);
|
||||||
|
} else if (readFileSync(OUTPUT_PATH, 'utf8') !== generated) {
|
||||||
|
console.error('entities.generated.ts is stale — it no longer matches the backend registry.');
|
||||||
|
console.error('Run: node scripts/gen-entity-types.mjs --write');
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log('entities.generated.ts is in step with the backend registry.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { createServer } from 'vite';
|
import { createServer } from 'vite';
|
||||||
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
|
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
|
||||||
|
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ const server = await createServer({
|
|||||||
logLevel: 'error',
|
logLevel: 'error',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Resolves the capture's module paths whatever extension they carry during the
|
||||||
|
TypeScript migration. The comparison this script performs is unaffected: it
|
||||||
|
loads the same modules and serialises the same values. */
|
||||||
|
withSourceResolution(server, ROOT);
|
||||||
|
|
||||||
const captured = await captureBaseline(server);
|
const captured = await captureBaseline(server);
|
||||||
await server.close();
|
await server.close();
|
||||||
|
|
||||||
|
|||||||
119
scripts/render-page.mjs
Normal file
119
scripts/render-page.mjs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Render an Admin page to static markup, outside a browser.
|
||||||
|
*
|
||||||
|
* The migration to the UI node tree has to be provable rather than asserted:
|
||||||
|
* a page is rendered before it is touched, rendered again afterwards, and the
|
||||||
|
* two are compared. This is the thing that renders it, used both to capture a
|
||||||
|
* baseline and, from the check script, to compare against one.
|
||||||
|
*
|
||||||
|
* node scripts/render-page.mjs src/pages/admin/HiredHistory.jsx out.html
|
||||||
|
*
|
||||||
|
* The page is loaded through a real Vite server, so `@/` aliases, Markdown
|
||||||
|
* imports and `import.meta.glob` behave exactly as they do in the app. Queries
|
||||||
|
* are disabled rather than mocked: every page then renders its empty state,
|
||||||
|
* deterministically, which is all a structural comparison needs.
|
||||||
|
*/
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import React from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The design system reads `window` when its modules evaluate, which is a
|
||||||
|
* pre-existing SSR limitation and not what any of this is testing. The shim is
|
||||||
|
* the same one the citation tests use.
|
||||||
|
*/
|
||||||
|
export function shimWindow() {
|
||||||
|
const had = 'window' in globalThis;
|
||||||
|
const hadSvg = 'SVGElement' in globalThis;
|
||||||
|
if (!had) {
|
||||||
|
globalThis.window = {
|
||||||
|
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||||||
|
addEventListener() {}, removeEventListener() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/* Recharts tests `instanceof SVGElement` while measuring, which is a browser
|
||||||
|
global with no Node equivalent. A bare class is enough: nothing is ever an
|
||||||
|
instance of it, which is the correct answer outside a browser. */
|
||||||
|
if (!hadSvg) globalThis.SVGElement = class SVGElement {};
|
||||||
|
return () => {
|
||||||
|
if (!had) delete globalThis.window;
|
||||||
|
if (!hadSvg) delete globalThis.SVGElement;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one page.
|
||||||
|
*
|
||||||
|
* `providers` are supplied by the caller rather than assumed here, because the
|
||||||
|
* editing session belongs to the layout and a baseline captured before a
|
||||||
|
* migration must be rendered without it.
|
||||||
|
*/
|
||||||
|
export async function renderPage(server, modulePath, { route = '/', wrap = null } = {}) {
|
||||||
|
/* Imported through Node rather than the Vite graph so the instance matches
|
||||||
|
the one the page itself resolves — loading them as SSR modules creates a
|
||||||
|
second copy, and a second QueryClientProvider provides nothing. */
|
||||||
|
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
|
||||||
|
const { MemoryRouter } = await import('react-router-dom');
|
||||||
|
|
||||||
|
const Page = (await server.ssrLoadModule(modulePath)).default;
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
|
||||||
|
|
||||||
|
const inner = wrap ? wrap(React.createElement(Page)) : React.createElement(Page);
|
||||||
|
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
React.createElement(MemoryRouter, { initialEntries: [route] },
|
||||||
|
React.createElement(QueryClientProvider, { client }, inner))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two renderings are the same page when they paint the same styled boxes, in
|
||||||
|
* the same order, around the same words.
|
||||||
|
*
|
||||||
|
* Compared this way rather than byte-for-byte because a migration legitimately
|
||||||
|
* adds `data-ui-*` identity attributes, and for components that cannot forward
|
||||||
|
* unknown props a wrapper element carrying nothing else. Both are invisible.
|
||||||
|
* A changed utility class, a reordered section or altered text moves one of
|
||||||
|
* these and fails.
|
||||||
|
*/
|
||||||
|
export const classSignature = (html) => (html.match(/class="[^"]*"/g) || []).join('\n');
|
||||||
|
export const wordSignature = (html) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
/** Tag counts, so an added element is visible and can be characterised. */
|
||||||
|
export const tagCounts = (html) => (html.match(/<\/?[a-z][a-z0-9-]*/gi) || [])
|
||||||
|
.map((t) => t.toLowerCase())
|
||||||
|
.reduce((acc, t) => ({ ...acc, [t]: (acc[t] || 0) + 1 }), {});
|
||||||
|
|
||||||
|
/* Run directly: capture a baseline. */
|
||||||
|
if (process.argv[1] && process.argv[1].endsWith('render-page.mjs')) {
|
||||||
|
const [modulePath, out, route] = process.argv.slice(2);
|
||||||
|
const restore = shimWindow();
|
||||||
|
const server = await createServer({
|
||||||
|
root: process.cwd(),
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
appType: 'custom',
|
||||||
|
logLevel: 'error',
|
||||||
|
resolve: { alias: { 'react-hot-toast': join(process.cwd(), 'scripts/stubs/react-hot-toast.js') } },
|
||||||
|
});
|
||||||
|
/* The page path arrives on the command line with whatever extension it has
|
||||||
|
today; during the TypeScript migration that may be .jsx or .tsx. */
|
||||||
|
withSourceResolution(server, process.cwd());
|
||||||
|
try {
|
||||||
|
const html = await renderPage(server, `/${modulePath.replace(/^\//, '')}`, { route: route || '/' });
|
||||||
|
writeFileSync(out, html);
|
||||||
|
console.log(`rendered ${html.length} chars -> ${out}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('RENDER FAILED:', error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
restore();
|
||||||
|
/* `server.close()` leaves a handle open often enough that the process hangs
|
||||||
|
without this, and a capture script that never exits is a capture script
|
||||||
|
nobody runs. */
|
||||||
|
process.exit(process.exitCode || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { createServer } from 'vite';
|
|||||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||||
|
|
||||||
export const FIXTURE_PATH = join(process.cwd(), '..', 'krow-backend', 'seed', 'fixtures', 'seed.json');
|
export const FIXTURE_PATH = join(process.cwd(), '..', 'krow-backend', 'seed', 'fixtures', 'seed.json');
|
||||||
|
|
||||||
@@ -30,10 +31,31 @@ export const GENERATED_NOTE =
|
|||||||
|
|
||||||
export function fixtureFrom(seedModule) {
|
export function fixtureFrom(seedModule) {
|
||||||
const { ShiftRecord, ...entities } = seedModule.seedData;
|
const { ShiftRecord, ...entities } = seedModule.seedData;
|
||||||
/* First key, so it is the first thing anyone opening the file reads. The Go
|
/* `_generated` first, so it is the first thing anyone opening the file reads.
|
||||||
loader unmarshals into a struct of demoUser + entities and ignores the
|
|
||||||
rest, so this costs nothing on the reading side. */
|
`users` is the channel the seeder actually reads — `internal/seeder`
|
||||||
return { _generated: GENERATED_NOTE, demoUser: seedModule.DEMO_USER, entities };
|
unmarshals into `{demoUser, users, entities}` and writes every account in
|
||||||
|
`users`, falling back to `demoUser` alone when the key is absent. That
|
||||||
|
fallback is why `demoUser` stays beside it rather than being replaced: a
|
||||||
|
fixture written here still seeds correctly against a backend that predates
|
||||||
|
the list, it simply seeds one account instead of two.
|
||||||
|
|
||||||
|
Emitting only `demoUser` is what this generator used to do, and it was a
|
||||||
|
version behind: the fixture on disk carries the employer account, the
|
||||||
|
seeder reads it from `users`, and regenerating without this key would drop
|
||||||
|
`employer@krow.app` from every fresh seed — leaving nobody to sign in as
|
||||||
|
to reach the employer console, which is the exact gap the account was
|
||||||
|
added to close.
|
||||||
|
|
||||||
|
`entities.User` carries the same list. The seeder ignores it, and it is
|
||||||
|
emitted because `seedData` is copied wholesale; the two are the same array
|
||||||
|
rather than two places to keep in step. */
|
||||||
|
return {
|
||||||
|
_generated: GENERATED_NOTE,
|
||||||
|
demoUser: seedModule.DEMO_USER,
|
||||||
|
users: seedModule.seedData.User,
|
||||||
|
entities,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serialised exactly as the committed file is: 2-space indent, no trailing newline. */
|
/** Serialised exactly as the committed file is: 2-space indent, no trailing newline. */
|
||||||
@@ -51,6 +73,9 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|||||||
const server = await createServer({
|
const server = await createServer({
|
||||||
root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', logLevel: 'error',
|
root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', logLevel: 'error',
|
||||||
});
|
});
|
||||||
|
/* `buildFixture` loads `/src/api/seed.js`, which the TypeScript migration
|
||||||
|
will rename. The fixture it generates is unchanged either way. */
|
||||||
|
withSourceResolution(server, process.cwd());
|
||||||
const built = await buildFixture(server);
|
const built = await buildFixture(server);
|
||||||
await server.close();
|
await server.close();
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
108
scripts/ssr-resolve.mjs
Normal file
108
scripts/ssr-resolve.mjs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Extension-agnostic SSR module loading, for the duration of the TypeScript
|
||||||
|
* migration.
|
||||||
|
*
|
||||||
|
* Every script in here addresses modules by literal path — `ssrLoadModule(
|
||||||
|
* '/src/lib/skills/registry.js')` — about a hundred and seventy times across
|
||||||
|
* `skill-check.mjs`, `owliver-capture.mjs`, `render-page.mjs` and
|
||||||
|
* `seed-fixture.mjs`. That is fine while every source file is JavaScript and
|
||||||
|
* fatal the moment one is not: renaming `registry.js` to `registry.ts` turns
|
||||||
|
* the check suite's very first load into a failure, and the suite is the only
|
||||||
|
* evidence the Owliver flow still behaves the way it did.
|
||||||
|
*
|
||||||
|
* Rewriting all those call sites would be a large, noisy, error-prone diff
|
||||||
|
* against the file that guards the migration — exactly the wrong thing to
|
||||||
|
* disturb. So the loader is wrapped once instead and the call sites keep the
|
||||||
|
* paths they already have, which stay readable as the names of real files.
|
||||||
|
*
|
||||||
|
* Resolution is by existence on disk, not by catching a failed load. A load
|
||||||
|
* that fails for a real reason — a syntax error, a bad import inside the module
|
||||||
|
* — must surface as itself; retrying under another extension would bury it
|
||||||
|
* behind a second, more confusing error about a file that was never there.
|
||||||
|
*
|
||||||
|
* The path as written is always tried first, so while a module is still
|
||||||
|
* JavaScript this changes nothing at all.
|
||||||
|
*
|
||||||
|
* This file is temporary. When `src` holds no `.js` or `.jsx` any more, the
|
||||||
|
* call sites can be renamed in one pass and this wrapper deleted.
|
||||||
|
*/
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The candidate paths for one module specifier, in the order they are tried.
|
||||||
|
*
|
||||||
|
* Only `.js` and `.jsx` are rewritten. Anything else — a bare specifier, a
|
||||||
|
* `.mjs` file, something under `/node_modules` — is returned untouched, because
|
||||||
|
* nothing in this migration renames it.
|
||||||
|
*
|
||||||
|
* `.js` is allowed to become `.tsx` as well as `.ts`. Not because any `.js`
|
||||||
|
* file here contains JSX today — none of the 93 does, checked with a parser
|
||||||
|
* rather than a guess — but because one may be renamed that way: the only
|
||||||
|
* `.js` under `src/pages` is `admin/positions/nodes.js`, sitting among seven
|
||||||
|
* sibling `nodes.jsx` files, and whoever converts that directory will
|
||||||
|
* reasonably want all eight to end in `.tsx`. The extra candidate costs one
|
||||||
|
* `existsSync` that answers no.
|
||||||
|
*/
|
||||||
|
export function candidatesFor(path) {
|
||||||
|
const specifier = String(path);
|
||||||
|
if (!/\.jsx?$/.test(specifier)) return [specifier];
|
||||||
|
const stem = specifier.replace(/\.jsx?$/, '');
|
||||||
|
return [...new Set([specifier, `${stem}.ts`, `${stem}.tsx`])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps `server.ssrLoadModule` so it finds a module whichever of the four
|
||||||
|
* extensions it currently carries.
|
||||||
|
*
|
||||||
|
* Mutates and returns the server, so it reads as one line after `createServer`
|
||||||
|
* and every later call — including the dynamically-built paths, which is why
|
||||||
|
* this is done here rather than at the call sites — goes through it.
|
||||||
|
*
|
||||||
|
* `root` is where the leading-slash paths are rooted; it defaults to the
|
||||||
|
* process's working directory, which is what every caller here uses.
|
||||||
|
*/
|
||||||
|
export function withSourceResolution(server, root = process.cwd()) {
|
||||||
|
const load = server.ssrLoadModule.bind(server);
|
||||||
|
|
||||||
|
server.ssrLoadModule = (path, options) => {
|
||||||
|
for (const candidate of candidatesFor(path)) {
|
||||||
|
if (existsSync(join(root, candidate.replace(/^\//, '')))) {
|
||||||
|
return load(candidate, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Nothing on disk under any extension. Load the path as written so the
|
||||||
|
error names what the caller actually asked for. */
|
||||||
|
return load(path, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same resolution, for source read as TEXT rather than loaded as a module.
|
||||||
|
*
|
||||||
|
* `skill-check.mjs` asserts structural facts by reading source files and
|
||||||
|
* matching against their contents — "the panel imports no local suggestion
|
||||||
|
* ranker", "no runtime path writes a definition". Those reads go through
|
||||||
|
* `readFileSync`, not `ssrLoadModule`, so `withSourceResolution` above never
|
||||||
|
* sees them: it wraps the loader, and this is a second, entirely separate
|
||||||
|
* channel. Renaming `base44Client.js` to `.ts` is what surfaced the difference,
|
||||||
|
* as an ENOENT in the middle of a suite that had been passing.
|
||||||
|
*
|
||||||
|
* Takes and returns a path RELATIVE to the project root, so the call site keeps
|
||||||
|
* its `join(ROOT, …)` exactly as it was:
|
||||||
|
*
|
||||||
|
* readFileSync(join(ROOT, resolveSourcePath('src/api/base44Client.js')), 'utf8')
|
||||||
|
*
|
||||||
|
* Unresolvable paths come back unchanged, so the resulting error still names
|
||||||
|
* the file the caller asked for rather than a candidate it invented. That also
|
||||||
|
* keeps the deliberately absent ones honest: a check asserting a file is GONE
|
||||||
|
* gets the path it asked about, and `existsSync` still answers false.
|
||||||
|
*/
|
||||||
|
export function resolveSourcePath(path, root = process.cwd()) {
|
||||||
|
for (const candidate of candidatesFor(path)) {
|
||||||
|
if (existsSync(join(root, candidate))) return candidate;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
19
scripts/stubs/react-hot-toast.js
vendored
Normal file
19
scripts/stubs/react-hot-toast.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* A no-op stand-in for `react-hot-toast`, used only by scripts/skill-check.mjs.
|
||||||
|
*
|
||||||
|
* The real package evaluates `goober`, which calls `document.createElement` the
|
||||||
|
* moment it is imported. That makes the whole design-system barrel — and so the
|
||||||
|
* Owliver block renderers — impossible to load in Node, which would leave the
|
||||||
|
* citation tests asserting on block objects rather than on rendered markup.
|
||||||
|
*
|
||||||
|
* Nothing under test ever raises a toast, so a stub costs nothing and buys the
|
||||||
|
* stronger assertion: the real renderers, producing real HTML.
|
||||||
|
*/
|
||||||
|
const noop = () => '';
|
||||||
|
export const toast = Object.assign(noop, {
|
||||||
|
success: noop, error: noop, loading: noop, custom: noop, dismiss: noop, remove: noop, promise: noop,
|
||||||
|
});
|
||||||
|
export const Toaster = () => null;
|
||||||
|
export const useToaster = () => ({ toasts: [], handlers: {} });
|
||||||
|
export const useToasterStore = () => ({ toasts: [] });
|
||||||
|
export default toast;
|
||||||
@@ -25,6 +25,7 @@ import WorkerProfile from '@/pages/WorkerProfile';
|
|||||||
import KrowIdentity from '@/pages/KrowIdentity';
|
import KrowIdentity from '@/pages/KrowIdentity';
|
||||||
import Owliver from '@/pages/Owliver';
|
import Owliver from '@/pages/Owliver';
|
||||||
import EmployeeDashboard from '@/pages/EmployeeDashboard';
|
import EmployeeDashboard from '@/pages/EmployeeDashboard';
|
||||||
|
import OpportunityDetail from '@/pages/OpportunityDetail';
|
||||||
import DesignSystem from '@/pages/DesignSystem';
|
import DesignSystem from '@/pages/DesignSystem';
|
||||||
|
|
||||||
/* Admin product — its own layout and route tree, so the Admin redesign can
|
/* Admin product — its own layout and route tree, so the Admin redesign can
|
||||||
@@ -37,6 +38,7 @@ import AdminCandidateProfile from '@/pages/admin/CandidateProfile';
|
|||||||
import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis';
|
import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis';
|
||||||
import AdminHiredHistory from '@/pages/admin/HiredHistory';
|
import AdminHiredHistory from '@/pages/admin/HiredHistory';
|
||||||
import AdminTalentPool from '@/pages/admin/TalentPool';
|
import AdminTalentPool from '@/pages/admin/TalentPool';
|
||||||
|
import AdminTalentProfile from '@/pages/admin/TalentProfile';
|
||||||
import AdminAnalytics from '@/pages/admin/Analytics';
|
import AdminAnalytics from '@/pages/admin/Analytics';
|
||||||
import AdminActivity from '@/pages/admin/Activity';
|
import AdminActivity from '@/pages/admin/Activity';
|
||||||
import AdminProfile from '@/pages/admin/Profile';
|
import AdminProfile from '@/pages/admin/Profile';
|
||||||
@@ -98,8 +100,20 @@ const AuthenticatedApp = () => {
|
|||||||
screening dimensions, which a 480px column cannot show without
|
screening dimensions, which a 480px column cannot show without
|
||||||
hiding most of it. */}
|
hiding most of it. */}
|
||||||
<Route path="candidates/:id" element={<AdminCandidateProfile />} />
|
<Route path="candidates/:id" element={<AdminCandidateProfile />} />
|
||||||
|
{/* Candidate Analysis. A sibling segment, not `candidates/:id`:
|
||||||
|
the analysis reads the whole pool, so it is not addressed by a
|
||||||
|
candidate id and must not be matched as one. The route is the
|
||||||
|
one the `candidates-analysis` surface declares, so the skill
|
||||||
|
table, the assistant placement table and this agree. */}
|
||||||
|
<Route path="candidates-analysis" element={<AdminCandidatesAnalysis />} />
|
||||||
<Route path="hired" element={<AdminHiredHistory />} />
|
<Route path="hired" element={<AdminHiredHistory />} />
|
||||||
<Route path="talent-pool" element={<AdminTalentPool />} />
|
<Route path="talent-pool" element={<AdminTalentPool />} />
|
||||||
|
{/* The person, keyed by worker profile — distinct from
|
||||||
|
`candidates/:id`, which is one person FOR ONE POSITION. Two
|
||||||
|
routes because they answer different questions; one screen
|
||||||
|
would push the product back towards a candidate record per
|
||||||
|
vacancy, which is the thing `worker_profiles` exists to stop. */}
|
||||||
|
<Route path="talent/:id" element={<AdminTalentProfile />} />
|
||||||
<Route path="university" element={<University />} />
|
<Route path="university" element={<University />} />
|
||||||
<Route path="university/:id" element={<CourseDetail />} />
|
<Route path="university/:id" element={<CourseDetail />} />
|
||||||
<Route path="analytics" element={<AdminAnalytics />} />
|
<Route path="analytics" element={<AdminAnalytics />} />
|
||||||
@@ -131,6 +145,12 @@ const AuthenticatedApp = () => {
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Redirect top-level legacy aliases to the unified global shell */}
|
{/* Redirect top-level legacy aliases to the unified global shell */}
|
||||||
|
{/* Employee (talent) opportunity flow — full-page routes, no drawer.
|
||||||
|
Standalone (outside AdminLayout/AdminRoute, which are the employer
|
||||||
|
shell and gate); authenticated via ProtectedRoute. */}
|
||||||
|
<Route path="/employee" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-5xl mx-auto px-4 py-8"><EmployeeDashboard /></div></div>} />
|
||||||
|
<Route path="/opportunities/:id" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-3xl mx-auto px-4 py-8"><OpportunityDetail /></div></div>} />
|
||||||
|
<Route path="/apply" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-3xl mx-auto px-4 py-8"><Apply /></div></div>} />
|
||||||
<Route path="/overview" element={<Navigate to="/admin" replace />} />
|
<Route path="/overview" element={<Navigate to="/admin" replace />} />
|
||||||
<Route path="/positions" element={<Navigate to="/admin/positions" replace />} />
|
<Route path="/positions" element={<Navigate to="/admin/positions" replace />} />
|
||||||
{/* `/positions` only matches the exact path, so the sub-routes need
|
{/* `/positions` only matches the exact path, so the sub-routes need
|
||||||
@@ -4,7 +4,7 @@ name: Positions Agent
|
|||||||
description: Open roles — what they need, who has applied, and which are at risk of going unfilled.
|
description: Open roles — what they need, who has applied, and which are at risk of going unfilled.
|
||||||
icon: briefcase
|
icon: briefcase
|
||||||
status: published
|
status: published
|
||||||
version: 1
|
version: 2
|
||||||
reasoning: balanced
|
reasoning: balanced
|
||||||
trigger: Use on Positions, for open roles, applicant flow, and specifying a new role.
|
trigger: Use on Positions, for open roles, applicant flow, and specifying a new role.
|
||||||
pages:
|
pages:
|
||||||
@@ -12,6 +12,7 @@ pages:
|
|||||||
- create-position
|
- create-position
|
||||||
skills:
|
skills:
|
||||||
- create-position
|
- create-position
|
||||||
|
- create-employee-role
|
||||||
- hiring-activity-assistant
|
- hiring-activity-assistant
|
||||||
- staffing-risk
|
- staffing-risk
|
||||||
starters:
|
starters:
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ name: Talent Pool Agent
|
|||||||
description: Available talent — who is in the pool, who is verified, and who is ready to place.
|
description: Available talent — who is in the pool, who is verified, and who is ready to place.
|
||||||
icon: layers
|
icon: layers
|
||||||
status: published
|
status: published
|
||||||
version: 1
|
version: 2
|
||||||
reasoning: balanced
|
reasoning: balanced
|
||||||
trigger: Use on Talent Pool, for supply, availability and readiness of known workers.
|
trigger: Use on Talent Pool, for supply, availability and readiness of known workers.
|
||||||
pages:
|
pages:
|
||||||
- talent-pool
|
- talent-pool
|
||||||
skills:
|
skills:
|
||||||
- talent-pool-analysis
|
- talent-pool-analysis
|
||||||
|
- create-employee-role
|
||||||
starters:
|
starters:
|
||||||
- label: Who is available?
|
- label: Who is available?
|
||||||
prompt: Who is available in the talent pool?
|
prompt: Who is available in the talent pool?
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* and results that actually respond to the data instead of being canned.
|
* and results that actually respond to the data instead of being canned.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
const clamp = (n: unknown, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||||
|
|
||||||
/** Simulated model latency so streaming/pending UI behaves as designed. */
|
/** Simulated model latency so streaming/pending UI behaves as designed. */
|
||||||
const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
|
const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -21,39 +21,39 @@ const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
|
|||||||
/* ── Prompt field readers ──────────────────────────────────────────────── */
|
/* ── Prompt field readers ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** Reads a `Label: value` line out of a prompt. */
|
/** Reads a `Label: value` line out of a prompt. */
|
||||||
function field(prompt, label) {
|
function field(prompt: string, label: string): string {
|
||||||
const match = prompt.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'mi'));
|
const match = prompt.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'mi'));
|
||||||
return match ? match[1].trim() : '';
|
return match ? match[1].trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function numField(prompt, label) {
|
function numField(prompt: string, label: string): number {
|
||||||
const raw = field(prompt, label);
|
const raw = field(prompt, label);
|
||||||
const match = raw.match(/-?\d+(\.\d+)?/);
|
const match = raw.match(/-?\d+(\.\d+)?/);
|
||||||
return match ? Number(match[0]) : 0;
|
return match ? Number(match[0]) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads a comma-separated line, treating the app's "None" sentinel as empty. */
|
/** Reads a comma-separated line, treating the app's "None" sentinel as empty. */
|
||||||
function listField(prompt, label) {
|
function listField(prompt: string, label: string): string[] {
|
||||||
const raw = field(prompt, label);
|
const raw = field(prompt, label);
|
||||||
if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return [];
|
if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return [];
|
||||||
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pulls the first triple-quoted block (transcripts, free-text resumes). */
|
/** Pulls the first triple-quoted block (transcripts, free-text resumes). */
|
||||||
function quotedBlock(prompt) {
|
function quotedBlock(prompt: string): string {
|
||||||
const match = prompt.match(/"""\s*([\s\S]*?)\s*"""/);
|
const match = prompt.match(/"""\s*([\s\S]*?)\s*"""/);
|
||||||
return match ? match[1].trim() : '';
|
return match ? match[1].trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENGLISH_RANK = { basic: 1, conversational: 2, fluent: 3, native: 4 };
|
const ENGLISH_RANK: Record<string, number> = { basic: 1, conversational: 2, fluent: 3, native: 4 };
|
||||||
|
|
||||||
const isSpanish = (prompt) => /Spanish \(español\)/.test(prompt) && !/default to English/.test(prompt);
|
const isSpanish = (prompt: string) => /Spanish \(español\)/.test(prompt) && !/default to English/.test(prompt);
|
||||||
|
|
||||||
/* ── 1. Candidate screening ────────────────────────────────────────────── */
|
/* ── 1. Candidate screening ────────────────────────────────────────────── */
|
||||||
|
|
||||||
function screenCandidate(prompt) {
|
function screenCandidate(prompt: string) {
|
||||||
// Weights are stated inline: "experience 25%, english 20%, …"
|
// Weights are stated inline: "experience 25%, english 20%, …"
|
||||||
const weightOf = (name) => {
|
const weightOf = (name: string) => {
|
||||||
const match = prompt.match(new RegExp(`${name} (\\d+)%`));
|
const match = prompt.match(new RegExp(`${name} (\\d+)%`));
|
||||||
return match ? Number(match[1]) : 20;
|
return match ? Number(match[1]) : 20;
|
||||||
};
|
};
|
||||||
@@ -184,7 +184,7 @@ function screenCandidate(prompt) {
|
|||||||
|
|
||||||
/* ── 2. Job description generator ──────────────────────────────────────── */
|
/* ── 2. Job description generator ──────────────────────────────────────── */
|
||||||
|
|
||||||
const RESPONSIBILITY_LIBRARY = {
|
const RESPONSIBILITY_LIBRARY: Record<string, string[]> = {
|
||||||
Bartender: [
|
Bartender: [
|
||||||
'Set up and break down a full service bar',
|
'Set up and break down a full service bar',
|
||||||
'Pour to spec at event pace without sacrificing presentation',
|
'Pour to spec at event pace without sacrificing presentation',
|
||||||
@@ -236,7 +236,7 @@ const DEFAULT_RESPONSIBILITIES = [
|
|||||||
'Complete close-out duties before leaving',
|
'Complete close-out duties before leaving',
|
||||||
];
|
];
|
||||||
|
|
||||||
function generateJobDescription(prompt) {
|
function generateJobDescription(prompt: string) {
|
||||||
const title = field(prompt, 'Role Title');
|
const title = field(prompt, 'Role Title');
|
||||||
const category = field(prompt, 'Role Category');
|
const category = field(prompt, 'Role Category');
|
||||||
const minYears = numField(prompt, 'Min Experience');
|
const minYears = numField(prompt, 'Min Experience');
|
||||||
@@ -301,7 +301,7 @@ const CERT_KEYWORDS = [
|
|||||||
['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'],
|
['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'],
|
||||||
];
|
];
|
||||||
|
|
||||||
function buildResume(prompt) {
|
function buildResume(prompt: string) {
|
||||||
const text = quotedBlock(prompt);
|
const text = quotedBlock(prompt);
|
||||||
const lower = text.toLowerCase();
|
const lower = text.toLowerCase();
|
||||||
|
|
||||||
@@ -343,14 +343,14 @@ function buildResume(prompt) {
|
|||||||
|
|
||||||
const INTERVIEW_QUESTIONS = {
|
const INTERVIEW_QUESTIONS = {
|
||||||
en: [
|
en: [
|
||||||
(role) => `Thanks for joining. To start — what drew you to ${role} work, and how long have you been doing it?`,
|
(role: string) => `Thanks for joining. To start — what drew you to ${role} work, and how long have you been doing it?`,
|
||||||
() => `Walk me through your busiest shift. What actually happened, and what did you do about it?`,
|
() => `Walk me through your busiest shift. What actually happened, and what did you do about it?`,
|
||||||
() => `Something goes wrong mid-service and your lead is unreachable. Talk me through your next three moves.`,
|
() => `Something goes wrong mid-service and your lead is unreachable. Talk me through your next three moves.`,
|
||||||
() => `Tell me about feedback that changed how you work. What did you do differently afterward?`,
|
() => `Tell me about feedback that changed how you work. What did you do differently afterward?`,
|
||||||
() => `Last one — what does your schedule realistically look like, and what do you want to be doing a year from now?`,
|
() => `Last one — what does your schedule realistically look like, and what do you want to be doing a year from now?`,
|
||||||
],
|
],
|
||||||
es: [
|
es: [
|
||||||
(role) => `Gracias por acompañarme. Para empezar — ¿qué te atrajo al trabajo de ${role}, y cuánto tiempo llevas haciéndolo?`,
|
(role: string) => `Gracias por acompañarme. Para empezar — ¿qué te atrajo al trabajo de ${role}, y cuánto tiempo llevas haciéndolo?`,
|
||||||
() => `Cuéntame de tu turno más ocupado. ¿Qué pasó realmente, y qué hiciste al respecto?`,
|
() => `Cuéntame de tu turno más ocupado. ¿Qué pasó realmente, y qué hiciste al respecto?`,
|
||||||
() => `Algo sale mal a mitad del servicio y no puedes contactar a tu supervisor. Explícame tus siguientes tres pasos.`,
|
() => `Algo sale mal a mitad del servicio y no puedes contactar a tu supervisor. Explícame tus siguientes tres pasos.`,
|
||||||
() => `Háblame de algún comentario que cambió tu manera de trabajar. ¿Qué hiciste diferente después?`,
|
() => `Háblame de algún comentario que cambió tu manera de trabajar. ¿Qué hiciste diferente después?`,
|
||||||
@@ -358,7 +358,7 @@ const INTERVIEW_QUESTIONS = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function interviewQuestion(prompt) {
|
function interviewQuestion(prompt: string) {
|
||||||
const lang = isSpanish(prompt) ? 'es' : 'en';
|
const lang = isSpanish(prompt) ? 'es' : 'en';
|
||||||
const numberMatch = prompt.match(/Ask question (\d+) of 5/);
|
const numberMatch = prompt.match(/Ask question (\d+) of 5/);
|
||||||
const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
||||||
@@ -369,7 +369,7 @@ function interviewQuestion(prompt) {
|
|||||||
|
|
||||||
const OWLIVER_QUESTIONS = {
|
const OWLIVER_QUESTIONS = {
|
||||||
en: [
|
en: [
|
||||||
(name) => `Hey ${name} — good to meet you. Tell me about yourself, in your own words.`,
|
(name: string) => `Hey ${name} — good to meet you. Tell me about yourself, in your own words.`,
|
||||||
() => `I like that. Tell me about something you got through that you're genuinely proud of.`,
|
() => `I like that. Tell me about something you got through that you're genuinely proud of.`,
|
||||||
() => `Picture this: you're the only one on shift, 300 guests arrive early, and the kitchen is behind. What do you do?`,
|
() => `Picture this: you're the only one on shift, 300 guests arrive early, and the kitchen is behind. What do you do?`,
|
||||||
() => `What's a piece of feedback that stuck with you — and what changed after it?`,
|
() => `What's a piece of feedback that stuck with you — and what changed after it?`,
|
||||||
@@ -377,7 +377,7 @@ const OWLIVER_QUESTIONS = {
|
|||||||
() => `That's everything I needed. Anything you want to add before I put this together?`,
|
() => `That's everything I needed. Anything you want to add before I put this together?`,
|
||||||
],
|
],
|
||||||
es: [
|
es: [
|
||||||
(name) => `Hola ${name} — un gusto conocerte. Cuéntame de ti, con tus propias palabras.`,
|
(name: string) => `Hola ${name} — un gusto conocerte. Cuéntame de ti, con tus propias palabras.`,
|
||||||
() => `Me gusta eso. Cuéntame de algo que superaste y de lo que estés realmente orgulloso.`,
|
() => `Me gusta eso. Cuéntame de algo que superaste y de lo que estés realmente orgulloso.`,
|
||||||
() => `Imagina: estás solo en el turno, llegan 300 invitados antes de tiempo y la cocina va atrasada. ¿Qué haces?`,
|
() => `Imagina: estás solo en el turno, llegan 300 invitados antes de tiempo y la cocina va atrasada. ¿Qué haces?`,
|
||||||
() => `¿Qué comentario se te quedó grabado — y qué cambió después?`,
|
() => `¿Qué comentario se te quedó grabado — y qué cambió después?`,
|
||||||
@@ -386,7 +386,7 @@ const OWLIVER_QUESTIONS = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function owliverQuestion(prompt) {
|
function owliverQuestion(prompt: string) {
|
||||||
const lang = isSpanish(prompt) ? 'es' : 'en';
|
const lang = isSpanish(prompt) ? 'es' : 'en';
|
||||||
const numberMatch = prompt.match(/you are on question (\d+)/);
|
const numberMatch = prompt.match(/you are on question (\d+)/);
|
||||||
const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
||||||
@@ -398,7 +398,7 @@ function owliverQuestion(prompt) {
|
|||||||
/* ── 5. Interview evaluation ───────────────────────────────────────────── */
|
/* ── 5. Interview evaluation ───────────────────────────────────────────── */
|
||||||
|
|
||||||
/** Scores a transcript on how substantive the candidate's own answers are. */
|
/** Scores a transcript on how substantive the candidate's own answers are. */
|
||||||
function readTranscript(prompt) {
|
function readTranscript(prompt: string) {
|
||||||
const transcript = prompt.split('TRANSCRIPT:')[1] || '';
|
const transcript = prompt.split('TRANSCRIPT:')[1] || '';
|
||||||
const answers = transcript
|
const answers = transcript
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@@ -412,7 +412,7 @@ function readTranscript(prompt) {
|
|||||||
return { answers, words, avgWords, specifics };
|
return { answers, words, avgWords, specifics };
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateInterview(prompt) {
|
function evaluateInterview(prompt: string) {
|
||||||
const { answers, avgWords, specifics } = readTranscript(prompt);
|
const { answers, avgWords, specifics } = readTranscript(prompt);
|
||||||
const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/);
|
const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/);
|
||||||
const fast = fastMatch ? Number(fastMatch[1]) : 0;
|
const fast = fastMatch ? Number(fastMatch[1]) : 0;
|
||||||
@@ -489,9 +489,14 @@ function evaluateInterview(prompt) {
|
|||||||
|
|
||||||
/* ── 6. Talent matching ────────────────────────────────────────────────── */
|
/* ── 6. Talent matching ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function matchTalent(prompt) {
|
function matchTalent(prompt: string) {
|
||||||
const block = prompt.match(/AVAILABLE TALENT \(JSON\):\s*(\[[\s\S]*?\])\s*\n\nYou are NEVER/);
|
const block = prompt.match(/AVAILABLE TALENT \(JSON\):\s*(\[[\s\S]*?\])\s*\n\nYou are NEVER/);
|
||||||
let pool = [];
|
/* Whatever the caller embedded in the prompt's talent-pool block, as
|
||||||
|
`JSON.parse` hands it back. Not `WorkerProfile[]`: this is a simulated
|
||||||
|
engine reading a blob out of free text, and the rows carry computed
|
||||||
|
fields like `match_score` that no entity declares. Typing it as a
|
||||||
|
record would assert a shape nothing validates. */
|
||||||
|
let pool: any[] = [];
|
||||||
try {
|
try {
|
||||||
pool = block ? JSON.parse(block[1]) : [];
|
pool = block ? JSON.parse(block[1]) : [];
|
||||||
} catch {
|
} catch {
|
||||||
@@ -513,9 +518,9 @@ function matchTalent(prompt) {
|
|||||||
? Math.min(20, ((p.experience_years || 0) / minYears) * 20)
|
? Math.min(20, ((p.experience_years || 0) / minYears) * 20)
|
||||||
: Math.min(20, (p.experience_years || 0) * 3);
|
: Math.min(20, (p.experience_years || 0) * 3);
|
||||||
|
|
||||||
const certs = (p.certifications || []).map((c) => c.toLowerCase());
|
const certs = (p.certifications || []).map((c: string) => c.toLowerCase());
|
||||||
const certFit = requiredCerts.length
|
const certFit = requiredCerts.length
|
||||||
? (requiredCerts.filter((r) => certs.some((c) => c.includes(r) || r.includes(c))).length / requiredCerts.length) * 15
|
? (requiredCerts.filter((r) => certs.some((c: string) => c.includes(r) || r.includes(c))).length / requiredCerts.length) * 15
|
||||||
: (certs.length ? 12 : 5);
|
: (certs.length ? 12 : 5);
|
||||||
|
|
||||||
const scoreFit = ((p.krow_score || 0) / 100) * 20;
|
const scoreFit = ((p.krow_score || 0) / 100) * 20;
|
||||||
@@ -547,7 +552,11 @@ function matchTalent(prompt) {
|
|||||||
/* ── 7. Owliver profile builder ────────────────────────────────────────── */
|
/* ── 7. Owliver profile builder ────────────────────────────────────────── */
|
||||||
|
|
||||||
/** Job titles a worker might name for themselves, most specific first. */
|
/** Job titles a worker might name for themselves, most specific first. */
|
||||||
const ROLE_TITLES = [
|
/* Explicit tuple, not `as const`: the entries are read as a pair — `.find(([re]) => re.test(…))`
|
||||||
|
then `role[1]` — and without it the element widens to `string | RegExp`, which has no `.test`.
|
||||||
|
`as const` would also work and would additionally make the array and its entries `readonly`,
|
||||||
|
a change to the type this module publishes for no benefit it uses. */
|
||||||
|
const ROLE_TITLES: [RegExp, string][] = [
|
||||||
[/banquet captain|captain/, 'Banquet Captain'],
|
[/banquet captain|captain/, 'Banquet Captain'],
|
||||||
[/executive chef/, 'Executive Chef'],
|
[/executive chef/, 'Executive Chef'],
|
||||||
[/sous chef/, 'Sous Chef'],
|
[/sous chef/, 'Sous Chef'],
|
||||||
@@ -563,7 +572,8 @@ const ROLE_TITLES = [
|
|||||||
[/manager/, 'Manager'],
|
[/manager/, 'Manager'],
|
||||||
];
|
];
|
||||||
|
|
||||||
const AVAILABILITY_TOKENS = [
|
/** Same pairing, same reason — matched by regex, collected by label. */
|
||||||
|
const AVAILABILITY_TOKENS: [RegExp, string][] = [
|
||||||
[/weekend|saturday|sunday|sábado|domingo/i, 'Weekends'],
|
[/weekend|saturday|sunday|sábado|domingo/i, 'Weekends'],
|
||||||
[/weekday|monday|tuesday|wednesday|thursday|friday|semana/i, 'Weekdays'],
|
[/weekday|monday|tuesday|wednesday|thursday|friday|semana/i, 'Weekdays'],
|
||||||
[/evening|night|noche|tarde/i, 'Evenings'],
|
[/evening|night|noche|tarde/i, 'Evenings'],
|
||||||
@@ -572,7 +582,7 @@ const AVAILABILITY_TOKENS = [
|
|||||||
[/on.?call|flexible/i, 'On-Call'],
|
[/on.?call|flexible/i, 'On-Call'],
|
||||||
];
|
];
|
||||||
|
|
||||||
function buildCareerDna(prompt) {
|
function buildCareerDna(prompt: string) {
|
||||||
const transcript = quotedBlock(prompt);
|
const transcript = quotedBlock(prompt);
|
||||||
const lower = transcript.toLowerCase();
|
const lower = transcript.toLowerCase();
|
||||||
const { answers, avgWords, specifics } = (() => {
|
const { answers, avgWords, specifics } = (() => {
|
||||||
@@ -696,7 +706,7 @@ function buildCareerDna(prompt) {
|
|||||||
|
|
||||||
/* ── 8. Proving Ground evaluator ───────────────────────────────────────── */
|
/* ── 8. Proving Ground evaluator ───────────────────────────────────────── */
|
||||||
|
|
||||||
function evaluateChallenge(prompt) {
|
function evaluateChallenge(prompt: string) {
|
||||||
const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/);
|
const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/);
|
||||||
const criteria = criteriaMatch
|
const criteria = criteriaMatch
|
||||||
? criteriaMatch[1].split(',').map((c) => c.trim()).filter(Boolean)
|
? criteriaMatch[1].split(',').map((c) => c.trim()).filter(Boolean)
|
||||||
@@ -743,7 +753,7 @@ function evaluateChallenge(prompt) {
|
|||||||
const verdict = base >= 70 ? 'verified' : base >= 50 ? 'needs_work' : 'failed';
|
const verdict = base >= 70 ? 'verified' : base >= 50 ? 'needs_work' : 'failed';
|
||||||
|
|
||||||
// Spread the overall score across the rubric so criteria are not identical.
|
// Spread the overall score across the rubric so criteria are not identical.
|
||||||
const rubric = {};
|
const rubric: Record<string, number> = {};
|
||||||
criteria.forEach((criterion, i) => {
|
criteria.forEach((criterion, i) => {
|
||||||
rubric[criterion] = clamp(base + (i % 3 === 0 ? 4 : i % 3 === 1 ? -3 : 1));
|
rubric[criterion] = clamp(base + (i % 3 === 0 ? 4 : i % 3 === 1 ? -3 : 1));
|
||||||
});
|
});
|
||||||
@@ -768,7 +778,15 @@ function evaluateChallenge(prompt) {
|
|||||||
* Recognizes each workflow by the phrase its prompt opens with. Order matters
|
* Recognizes each workflow by the phrase its prompt opens with. Order matters
|
||||||
* only in that every branch is mutually exclusive by design.
|
* only in that every branch is mutually exclusive by design.
|
||||||
*/
|
*/
|
||||||
const ROUTES = [
|
/* `[pattern, handler, latencyMs]`. Spelled out because all three positions are used for what
|
||||||
|
they are: `.test()` on the first, a call on the second, and `think(ms)` on the third. Left to
|
||||||
|
inference the element becomes the union of all three and none of those work.
|
||||||
|
|
||||||
|
The handler returns `any` deliberately. The ten handlers below return ten different shapes,
|
||||||
|
which is the point of a router — and `krowAi.js` relies on it: one caller does
|
||||||
|
`typeof res === 'string' ? res : res.text || String(res)`, which a precise union would
|
||||||
|
reject on every branch that has no `.text`. */
|
||||||
|
const ROUTES: [RegExp, (prompt: string) => any, number][] = [
|
||||||
[/^You are KROW's AI screening engine/m, screenCandidate, 1100],
|
[/^You are KROW's AI screening engine/m, screenCandidate, 1100],
|
||||||
[/^You are an expert hiring copywriter/m, generateJobDescription, 1400],
|
[/^You are an expert hiring copywriter/m, generateJobDescription, 1400],
|
||||||
[/Extract and infer a structured resume/m, buildResume, 1200],
|
[/Extract and infer a structured resume/m, buildResume, 1200],
|
||||||
@@ -782,8 +800,32 @@ const ROUTES = [
|
|||||||
() => 'Walk me through the exact order you would do that in — what happens first, and who do you tell?', 700],
|
() => 'Walk me through the exact order you would do that in — what happens first, and who do you tell?', 700],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Drop-in replacement for `integrations.Core.InvokeLLM`. */
|
/**
|
||||||
export async function invokeLLM({ prompt = '', response_json_schema: schema } = {}) {
|
* What a caller may send. Every field optional, because the destructuring default `= {}`
|
||||||
|
* says a call with no argument at all is legal, and it is one this module answers.
|
||||||
|
*
|
||||||
|
* `model` and `file_urls` are not read here — the local engine routes on the prompt and
|
||||||
|
* ignores both — but callers send them, and a parameter type that omitted them would reject
|
||||||
|
* eight call sites in `krowAi.js` and one in `provingGround.ts` for passing a field the real
|
||||||
|
* integration accepts. Declaring them describes the call this function is a drop-in for.
|
||||||
|
*/
|
||||||
|
export interface InvokeLLMOptions {
|
||||||
|
prompt?: string;
|
||||||
|
response_json_schema?: any;
|
||||||
|
model?: string;
|
||||||
|
file_urls?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop-in replacement for `integrations.Core.InvokeLLM`.
|
||||||
|
*
|
||||||
|
* The return stays `any` on purpose. See the note on `ROUTES`: the shape depends on which
|
||||||
|
* handler matched, and narrowing it here would break callers that branch on the result at
|
||||||
|
* run time rather than by type.
|
||||||
|
*/
|
||||||
|
export async function invokeLLM(
|
||||||
|
{ prompt = '', response_json_schema: schema }: InvokeLLMOptions = {},
|
||||||
|
): Promise<any> {
|
||||||
const route = ROUTES.find(([pattern]) => pattern.test(prompt));
|
const route = ROUTES.find(([pattern]) => pattern.test(prompt));
|
||||||
|
|
||||||
if (!route) {
|
if (!route) {
|
||||||
@@ -800,7 +842,7 @@ export async function invokeLLM({ prompt = '', response_json_schema: schema } =
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Drop-in replacement for `integrations.Core.UploadFile`. */
|
/** Drop-in replacement for `integrations.Core.UploadFile`. */
|
||||||
export async function uploadFile({ file } = {}) {
|
export async function uploadFile({ file }: { file?: File } = {}) {
|
||||||
await think(500);
|
await think(500);
|
||||||
if (!file) return { file_url: '' };
|
if (!file) return { file_url: '' };
|
||||||
// A blob URL keeps uploaded media viewable for the rest of the session
|
// A blob URL keeps uploaded media viewable for the rest of the session
|
||||||
@@ -39,15 +39,15 @@ const WINDOW_DAYS = 56;
|
|||||||
* Local rather than UTC because a shift belongs to the day it was worked in the
|
* Local rather than UTC because a shift belongs to the day it was worked in the
|
||||||
* place it was worked, and `periodRange` windows on local day boundaries too.
|
* place it was worked, and `periodRange` windows on local day boundaries too.
|
||||||
*/
|
*/
|
||||||
function daysAgo(n, hour = 9, minute = 0, anchor = new Date()) {
|
function daysAgo(n: number, hour = 9, minute = 0, anchor = new Date()) {
|
||||||
const d = new Date(anchor.getTime());
|
const d = new Date(anchor.getTime());
|
||||||
d.setDate(d.getDate() - n);
|
d.setDate(d.getDate() - n);
|
||||||
d.setHours(hour, minute, 0, 0);
|
d.setHours(hour, minute, 0, 0);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
const round1 = (n) => Math.round(n * 10) / 10;
|
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||||
const round2 = (n) => Math.round(n * 100) / 100;
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||||
const HOUR = 60 * 60 * 1000;
|
const HOUR = 60 * 60 * 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,10 +105,21 @@ const ROSTER = [
|
|||||||
* Returning a plain record keeps every rule visible in one place instead of
|
* Returning a plain record keeps every rule visible in one place instead of
|
||||||
* spread across the generator.
|
* spread across the generator.
|
||||||
*/
|
*/
|
||||||
const BEHAVIOUR = {
|
/**
|
||||||
|
* One worker's shift outcome, as the rules below return it.
|
||||||
|
*
|
||||||
|
* `i` is the index of the shift counting back from the anchor, `weekday` the
|
||||||
|
* day it falls on. Rules that ignore the weekday take one argument, which is
|
||||||
|
* why the second is optional here.
|
||||||
|
*/
|
||||||
|
type ShiftBehaviour = (i: number, weekday?: number) => {
|
||||||
|
status: string; minutesLate: number; overtime: number; notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BEHAVIOUR: Record<string, ShiftBehaviour> = {
|
||||||
/* Reliable. One late arrival every couple of months, and overtime only on
|
/* Reliable. One late arrival every couple of months, and overtime only on
|
||||||
the nights events actually overrun. */
|
the nights events actually overrun. */
|
||||||
staff_marco: (i, weekday) => ({
|
staff_marco: (i: number, weekday?: number) => ({
|
||||||
status: i === 14 ? 'late' : 'present',
|
status: i === 14 ? 'late' : 'present',
|
||||||
minutesLate: i === 14 ? 9 : 0,
|
minutesLate: i === 14 ? 9 : 0,
|
||||||
/* Friday and Saturday events overrun; midweek ones do not. */
|
/* Friday and Saturday events overrun; midweek ones do not. */
|
||||||
@@ -124,7 +135,7 @@ const BEHAVIOUR = {
|
|||||||
* the six weeks before it — a change big enough to be worth surfacing and
|
* the six weeks before it — a change big enough to be worth surfacing and
|
||||||
* specific enough to act on.
|
* specific enough to act on.
|
||||||
*/
|
*/
|
||||||
staff_marcus: (i) => {
|
staff_marcus: (i: number) => {
|
||||||
if (i === 2 || i === 7) {
|
if (i === 2 || i === 7) {
|
||||||
return { status: 'absent', minutesLate: 0, overtime: 0, notes: 'Called in sick' };
|
return { status: 'absent', minutesLate: 0, overtime: 0, notes: 'Called in sick' };
|
||||||
}
|
}
|
||||||
@@ -145,7 +156,7 @@ const BEHAVIOUR = {
|
|||||||
* covers, on the three busiest shifts of each week. A steady climb rather
|
* covers, on the three busiest shifts of each week. A steady climb rather
|
||||||
* than a spike, which is exactly the shape that hides in a table of totals.
|
* than a spike, which is exactly the shape that hides in a table of totals.
|
||||||
*/
|
*/
|
||||||
staff_antoine: (i, weekday) => {
|
staff_antoine: (i: number, weekday?: number) => {
|
||||||
const weekIndex = Math.floor(i / 5);
|
const weekIndex = Math.floor(i / 5);
|
||||||
const busy = weekday === 4 || weekday === 5 || weekday === 6;
|
const busy = weekday === 4 || weekday === 5 || weekday === 6;
|
||||||
const overtime = busy ? Math.max(0.5, round1(3.5 - weekIndex * 0.45)) : 0;
|
const overtime = busy ? Math.max(0.5, round1(3.5 - weekIndex * 0.45)) : 0;
|
||||||
@@ -154,8 +165,8 @@ const BEHAVIOUR = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Every shift date for one worker, most recent first. */
|
/** Every shift date for one worker, most recent first. */
|
||||||
function shiftOffsets(weekdays, anchor) {
|
function shiftOffsets(weekdays: number[], anchor: Date) {
|
||||||
const offsets = [];
|
const offsets: number[] = [];
|
||||||
for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) {
|
for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) {
|
||||||
const day = daysAgo(offset, 9, 0, anchor).getDay();
|
const day = daysAgo(offset, 9, 0, anchor).getDay();
|
||||||
if (weekdays.includes(day)) offsets.push(offset);
|
if (weekdays.includes(day)) offsets.push(offset);
|
||||||
@@ -163,16 +174,16 @@ function shiftOffsets(weekdays, anchor) {
|
|||||||
return offsets;
|
return offsets;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
const localDate = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Date} [anchor] the day to count back from. Defaults to now, which is
|
* @param {Date} [anchor] the day to count back from. Defaults to now, which is
|
||||||
* the point of this collection; a caller passes one only to hold the window
|
* the point of this collection; a caller passes one only to hold the window
|
||||||
* still — see buildShiftsAt.
|
* still — see buildShiftsAt.
|
||||||
*/
|
*/
|
||||||
function buildShifts(anchor = new Date()) {
|
function buildShifts(anchor: Date = new Date()) {
|
||||||
const records = [];
|
const records: any[] = [];
|
||||||
|
|
||||||
for (const worker of ROSTER) {
|
for (const worker of ROSTER) {
|
||||||
const offsets = shiftOffsets(worker.weekdays, anchor);
|
const offsets = shiftOffsets(worker.weekdays, anchor);
|
||||||
@@ -251,6 +262,6 @@ export const SHIFT_RECORDS = buildShifts();
|
|||||||
* shifts built around one day and bucketed around another describe two
|
* shifts built around one day and bucketed around another describe two
|
||||||
* different windows.
|
* different windows.
|
||||||
*/
|
*/
|
||||||
export function buildShiftsAt(anchor) {
|
export function buildShiftsAt(anchor: Date) {
|
||||||
return buildShifts(anchor);
|
return buildShifts(anchor);
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
|
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
|
||||||
|
import type { PreferencesUpdateResult } from '@/types/user';
|
||||||
import { invokeLLM, uploadFile } from './aiEngine';
|
import { invokeLLM, uploadFile } from './aiEngine';
|
||||||
import { DEMO_USER } from './demoUser';
|
import { DEMO_USER } from './demoUser';
|
||||||
|
|
||||||
@@ -29,6 +30,11 @@ const ENTITY_NAMES = [
|
|||||||
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
||||||
'Course', 'Badge', 'LearningPath', 'Certification', 'RoleCategory',
|
'Course', 'Badge', 'LearningPath', 'Certification', 'RoleCategory',
|
||||||
'UserActivity', 'Evidence', 'User',
|
'UserActivity', 'Evidence', 'User',
|
||||||
|
/* What a worker declares they DO — role, experience, desired pay,
|
||||||
|
availability. The supply side of JobPosting, which is what the organization
|
||||||
|
needs filled. The two meet through JobApplication, not through a reference
|
||||||
|
between them. */
|
||||||
|
'EmployeeRole',
|
||||||
/* Who is on which position, and for how long. The record that turns "hired"
|
/* Who is on which position, and for how long. The record that turns "hired"
|
||||||
into workforce allocation: without it a position knows its demand and its
|
into workforce allocation: without it a position knows its demand and its
|
||||||
applicants but not who is actually covering it. */
|
applicants but not who is actually covering it. */
|
||||||
@@ -138,14 +144,18 @@ let authenticated = false;
|
|||||||
* A 401 here is the ordinary state of a signed-out visitor, not a failure: the
|
* A 401 here is the ordinary state of a signed-out visitor, not a failure: the
|
||||||
* app opens on the login page and this request is how it finds that out.
|
* app opens on the login page and this request is how it finds that out.
|
||||||
*/
|
*/
|
||||||
let hydration = request('GET', '/me')
|
/* `Promise<any> | null`, because the runtime already assigns both: the shared
|
||||||
|
first request, then `null` once it has been consumed or superseded by a
|
||||||
|
login. Inference alone would fix the type at `Promise<any>` and reject the
|
||||||
|
two `hydration = null` assignments that make the sharing work. */
|
||||||
|
let hydration: Promise<any> | null = request('GET', '/me')
|
||||||
.then((user) => {
|
.then((user) => {
|
||||||
currentUser = user;
|
currentUser = user;
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
cacheUser();
|
cacheUser();
|
||||||
return user;
|
return user;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((): null => {
|
||||||
authenticated = false;
|
authenticated = false;
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@@ -199,7 +209,11 @@ const auth = {
|
|||||||
* by design: telling the two apart would say whether an address is
|
* by design: telling the two apart would say whether an address is
|
||||||
* registered. The caller shows that message as-is.
|
* registered. The caller shows that message as-is.
|
||||||
*/
|
*/
|
||||||
async login({ email, password, rememberMe = false }) {
|
async login({ email, password, rememberMe = false }: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
rememberMe?: boolean;
|
||||||
|
}) {
|
||||||
const user = await request('POST', '/auth/login', {
|
const user = await request('POST', '/auth/login', {
|
||||||
body: { email, password, remember_me: Boolean(rememberMe) },
|
body: { email, password, remember_me: Boolean(rememberMe) },
|
||||||
});
|
});
|
||||||
@@ -210,7 +224,7 @@ const auth = {
|
|||||||
return { ...user };
|
return { ...user };
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateMe(patch) {
|
async updateMe(patch: any) {
|
||||||
const user = await request('PATCH', '/me', { body: patch });
|
const user = await request('PATCH', '/me', { body: patch });
|
||||||
currentUser = user;
|
currentUser = user;
|
||||||
cacheUser();
|
cacheUser();
|
||||||
@@ -242,7 +256,7 @@ const auth = {
|
|||||||
* question the shape exists to answer is now answered by whether this
|
* question the shape exists to answer is now answered by whether this
|
||||||
* function resolved at all.
|
* function resolved at all.
|
||||||
*/
|
*/
|
||||||
async updatePreferences(patch) {
|
async updatePreferences(patch: any): Promise<PreferencesUpdateResult> {
|
||||||
const preferences = await request('PATCH', '/me/preferences', { body: patch });
|
const preferences = await request('PATCH', '/me/preferences', { body: patch });
|
||||||
currentUser = { ...currentUser, preferences };
|
currentUser = { ...currentUser, preferences };
|
||||||
cacheUser();
|
cacheUser();
|
||||||
@@ -269,7 +283,7 @@ const auth = {
|
|||||||
* reach the API must still leave the browser signed out locally, and the
|
* reach the API must still leave the browser signed out locally, and the
|
||||||
* cookie it keeps will be refused by every request it is sent on.
|
* cookie it keeps will be refused by every request it is sent on.
|
||||||
*/
|
*/
|
||||||
async logout(redirectTo = '/admin/login') {
|
async logout(redirectTo: string = '/admin/login') {
|
||||||
try {
|
try {
|
||||||
await request('POST', '/auth/logout');
|
await request('POST', '/auth/logout');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -315,7 +329,7 @@ const workflows = {
|
|||||||
* across from the application by the server, because it is already the truth
|
* across from the application by the server, because it is already the truth
|
||||||
* about this person and retyping it here is how the two records drift apart.
|
* about this person and retyping it here is how the two records drift apart.
|
||||||
*/
|
*/
|
||||||
async hire(applicationId, body = {}) {
|
async hire(applicationId: string, body: any = {}) {
|
||||||
return request('POST', `/job-applications/${encodeURIComponent(applicationId)}/hire`, {
|
return request('POST', `/job-applications/${encodeURIComponent(applicationId)}/hire`, {
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
@@ -331,7 +345,7 @@ const workflows = {
|
|||||||
* inside the same transaction; a worker taken straight from the talent pool
|
* inside the same transaction; a worker taken straight from the talent pool
|
||||||
* with neither is placed without one rather than given an invented one.
|
* with neither is placed without one rather than given an invented one.
|
||||||
*/
|
*/
|
||||||
async assign(jobPostingId, workers = []) {
|
async assign(jobPostingId: string, workers: any[] = []) {
|
||||||
return request('POST', `/job-postings/${encodeURIComponent(jobPostingId)}/assignments`, {
|
return request('POST', `/job-postings/${encodeURIComponent(jobPostingId)}/assignments`, {
|
||||||
body: { workers },
|
body: { workers },
|
||||||
});
|
});
|
||||||
@@ -363,7 +377,7 @@ const workflows = {
|
|||||||
*/
|
*/
|
||||||
const owliver = {
|
const owliver = {
|
||||||
/** @param {any} request */
|
/** @param {any} request */
|
||||||
async suggestions({ page, query = '' } = {}) {
|
async suggestions({ page, query = '' }: { page?: string; query?: string } = {}) {
|
||||||
if (!page) return [];
|
if (!page) return [];
|
||||||
const typed = String(query || '').trim();
|
const typed = String(query || '').trim();
|
||||||
const data = await request('GET', '/owliver/suggestions', {
|
const data = await request('GET', '/owliver/suggestions', {
|
||||||
@@ -383,7 +397,7 @@ const integrations = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const analytics = {
|
const analytics = {
|
||||||
track({ eventName, properties } = {}) {
|
track({ eventName, properties }: { eventName?: string; properties?: any } = {}) {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.debug('[krow-demo] analytics', eventName, properties || {});
|
console.debug('[krow-demo] analytics', eventName, properties || {});
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { User } from '@/types/user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shape of a signed-in user, before the server has answered.
|
* The shape of a signed-in user, before the server has answered.
|
||||||
*
|
*
|
||||||
@@ -22,7 +24,7 @@
|
|||||||
* the server has never stored still has to resolve to something rather than to
|
* the server has never stored still has to resolve to something rather than to
|
||||||
* `undefined`.
|
* `undefined`.
|
||||||
*/
|
*/
|
||||||
export const DEMO_USER = {
|
export const DEMO_USER: User = {
|
||||||
id: 'user_demo',
|
id: 'user_demo',
|
||||||
full_name: 'Alex Rivera',
|
full_name: 'Alex Rivera',
|
||||||
email: 'demo@krow.app',
|
email: 'demo@krow.app',
|
||||||
@@ -37,3 +39,29 @@ export const DEMO_USER = {
|
|||||||
emailDigest: true,
|
emailDigest: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The employer account, and the reason there is a second one at all.
|
||||||
|
*
|
||||||
|
* `policy.go` has always had three roles — admin, employer, talent — and the
|
||||||
|
* fixture had one administrator, so two thirds of the authorization table was
|
||||||
|
* never exercised and there was nobody to sign in as to reach the employer
|
||||||
|
* console. This account is what makes that path reachable.
|
||||||
|
*
|
||||||
|
* Unlike `DEMO_USER` this is not a default shape for the first render: nothing
|
||||||
|
* in the running app reads it. It exists so that `seedData.User` names both
|
||||||
|
* accounts, which is what the backend seeder writes. See `seed.ts`.
|
||||||
|
*/
|
||||||
|
export const EMPLOYER_USER: User = {
|
||||||
|
id: 'user_employer',
|
||||||
|
full_name: 'Jordan Blake',
|
||||||
|
email: 'employer@krow.app',
|
||||||
|
role: 'employer',
|
||||||
|
account_type: 'employer',
|
||||||
|
created_date: '2026-06-01T09:00:00.000Z',
|
||||||
|
preferences: {
|
||||||
|
owliverDefault: true,
|
||||||
|
compactDensity: false,
|
||||||
|
emailDigest: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -27,6 +27,9 @@
|
|||||||
* did not answer.
|
* did not answer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ApiErrorResponse, KrowApiError } from '@/types/api';
|
||||||
|
import type { EntityName, EntityResourcePath } from '@/types/entities';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the API lives.
|
* Where the API lives.
|
||||||
*
|
*
|
||||||
@@ -93,12 +96,19 @@ if (import.meta.env?.DEV && /^https?:\/\//i.test(API_BASE_URL)) {
|
|||||||
* `user-activity` is three special cases wearing a trench coat, and a wrong
|
* `user-activity` is three special cases wearing a trench coat, and a wrong
|
||||||
* guess here is a 404 at runtime instead of a mistake anyone can see.
|
* guess here is a 404 at runtime instead of a mistake anyone can see.
|
||||||
*/
|
*/
|
||||||
const RESOURCE_PATHS = {
|
/* `Record<EntityName, EntityResourcePath>` is doing two checks at once, and
|
||||||
|
both were previously unavailable. Every one of the eighteen names must be
|
||||||
|
present as a key — so this map and `ENTITY_NAMES` in `base44Client.js` can no
|
||||||
|
longer drift apart silently — and every value must be one of the declared
|
||||||
|
paths, so a typo in a resource segment is a compile error rather than a 404
|
||||||
|
at run time. The object itself is unchanged, key for key. */
|
||||||
|
const RESOURCE_PATHS: Record<EntityName, EntityResourcePath> = {
|
||||||
JobPosting: 'job-postings',
|
JobPosting: 'job-postings',
|
||||||
JobApplication: 'job-applications',
|
JobApplication: 'job-applications',
|
||||||
AIInterview: 'ai-interviews',
|
AIInterview: 'ai-interviews',
|
||||||
Staff: 'staff',
|
Staff: 'staff',
|
||||||
WorkerProfile: 'worker-profiles',
|
WorkerProfile: 'worker-profiles',
|
||||||
|
EmployeeRole: 'employee-roles',
|
||||||
Course: 'courses',
|
Course: 'courses',
|
||||||
Badge: 'badges',
|
Badge: 'badges',
|
||||||
LearningPath: 'learning-paths',
|
LearningPath: 'learning-paths',
|
||||||
@@ -127,7 +137,7 @@ const RESOURCE_PATHS = {
|
|||||||
* `got === undefined` against real values and matched nothing, and sending the
|
* `got === undefined` against real values and matched nothing, and sending the
|
||||||
* string "undefined" would be a filter on a value no column holds.
|
* string "undefined" would be a filter on a value no column holds.
|
||||||
*/
|
*/
|
||||||
function queryString(params) {
|
function queryString(params: Record<string, unknown>) {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
if (value === undefined) continue;
|
if (value === undefined) continue;
|
||||||
@@ -152,9 +162,14 @@ function queryString(params) {
|
|||||||
* ride along as properties — new information a local store never had, and
|
* ride along as properties — new information a local store never had, and
|
||||||
* additive, so nothing that only reads `.message` notices.
|
* additive, so nothing that only reads `.message` notices.
|
||||||
*/
|
*/
|
||||||
function apiError(status, payload) {
|
function apiError(status: number, payload: ApiErrorResponse | null): KrowApiError {
|
||||||
const body = payload?.error;
|
const body = payload?.error;
|
||||||
const error = new Error(body?.message || `Request failed with status ${status}`);
|
/* Cast, not `class KrowApiError extends Error`. The properties below are
|
||||||
|
assigned onto a plain Error exactly as they always were, so the prototype
|
||||||
|
chain, `instanceof Error`, and the `error.name === 'KrowApiError'` test
|
||||||
|
callers use all behave identically. A subclass would change all three, and
|
||||||
|
the emitted JavaScript with it. */
|
||||||
|
const error = new Error(body?.message || `Request failed with status ${status}`) as KrowApiError;
|
||||||
error.name = 'KrowApiError';
|
error.name = 'KrowApiError';
|
||||||
error.status = status;
|
error.status = status;
|
||||||
error.code = body?.code || 'internal';
|
error.code = body?.code || 'internal';
|
||||||
@@ -170,10 +185,14 @@ function apiError(status, payload) {
|
|||||||
* reads it (§4.2), and surfacing it would mean changing what the six methods
|
* reads it (§4.2), and surfacing it would mean changing what the six methods
|
||||||
* return, which is the one thing Phase 2D must not do.
|
* return, which is the one thing Phase 2D must not do.
|
||||||
*/
|
*/
|
||||||
async function request(method, path, { query, body } = {}) {
|
async function request(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
{ query, body }: { query?: Record<string, unknown>; body?: unknown } = {},
|
||||||
|
) {
|
||||||
const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`;
|
const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`;
|
||||||
|
|
||||||
let response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, {
|
response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
@@ -198,7 +217,7 @@ async function request(method, path, { query, body } = {}) {
|
|||||||
`Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` +
|
`Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` +
|
||||||
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
|
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
|
||||||
`(${method} ${path})`
|
`(${method} ${path})`
|
||||||
);
|
) as KrowApiError;
|
||||||
error.name = 'KrowApiError';
|
error.name = 'KrowApiError';
|
||||||
error.status = 0;
|
error.status = 0;
|
||||||
error.code = 'unreachable';
|
error.code = 'unreachable';
|
||||||
@@ -220,7 +239,7 @@ async function request(method, path, { query, body } = {}) {
|
|||||||
if (!response.ok) throw apiError(response.status, payload);
|
if (!response.ok) throw apiError(response.status, payload);
|
||||||
|
|
||||||
if (payload === null) {
|
if (payload === null) {
|
||||||
const error = new Error(`${method} ${path} returned no JSON body`);
|
const error = new Error(`${method} ${path} returned no JSON body`) as KrowApiError;
|
||||||
error.name = 'KrowApiError';
|
error.name = 'KrowApiError';
|
||||||
error.status = response.status;
|
error.status = response.status;
|
||||||
error.code = 'internal';
|
error.code = 'internal';
|
||||||
@@ -241,7 +260,7 @@ export { request };
|
|||||||
* before the first login and after a session expires — so callers need to tell
|
* before the first login and after a session expires — so callers need to tell
|
||||||
* it apart from a real failure rather than treating both as "something broke".
|
* it apart from a real failure rather than treating both as "something broke".
|
||||||
*/
|
*/
|
||||||
export function isUnauthenticated(error) {
|
export function isUnauthenticated(error: any) {
|
||||||
return Boolean(error) && (error.status === 401 || error.code === 'unauthorized');
|
return Boolean(error) && (error.status === 401 || error.code === 'unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,13 +274,33 @@ export function isUnauthenticated(error) {
|
|||||||
* `list(sort = '-created_date', limit = 100)`, and several call sites rely on
|
* `list(sort = '-created_date', limit = 100)`, and several call sites rely on
|
||||||
* them rather than passing their own.
|
* them rather than passing their own.
|
||||||
*/
|
*/
|
||||||
export function createEntity(name) {
|
export function createEntity(name: string) {
|
||||||
const path = RESOURCE_PATHS[name];
|
/* `name` stays `string`, and the cast below is deliberate. Typing the
|
||||||
|
parameter as `EntityName` would be tighter and would immediately break the
|
||||||
|
one caller — `base44Client.js` builds the entity map by mapping over a
|
||||||
|
plain string array — which is a file this phase does not touch.
|
||||||
|
|
||||||
|
The return type is left inferred, and that is a decision rather than an
|
||||||
|
omission. Annotating it `EntityClient<any>` was tried and reverted: it
|
||||||
|
makes `list()` return `Promise<any[]>` where inference gives
|
||||||
|
`Promise<any>`, and two call sites in `src/lib/agents/agentStore.js` do
|
||||||
|
|
||||||
|
const rows = qc.getQueryData(KEY) || (await …list('-created_date', 200));
|
||||||
|
|
||||||
|
React Query types `getQueryData` as `unknown`, and `unknown || any`
|
||||||
|
collapses to `any` while `unknown || any[]` stays `unknown` — so `.find`
|
||||||
|
on the next line stopped compiling. Two new errors in a file this phase is
|
||||||
|
not migrating, for no gain: the surface is structurally identical either
|
||||||
|
way, and the emitted JavaScript is byte-for-byte the same.
|
||||||
|
|
||||||
|
`EntityClientFor<K>` and the generated record types are ready for the phase
|
||||||
|
that migrates those consumers. Nothing here has to change then. */
|
||||||
|
const path = RESOURCE_PATHS[name as EntityName];
|
||||||
if (!path) throw new Error(`No API resource path is declared for entity ${name}`);
|
if (!path) throw new Error(`No API resource path is declared for entity ${name}`);
|
||||||
const base = `/${path}`;
|
const base = `/${path}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entityName: name,
|
entityName: name as EntityName,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `sort` is always sent, even when empty. `?sort=` is not the same as
|
* `sort` is always sent, even when empty. `?sort=` is not the same as
|
||||||
@@ -280,19 +319,19 @@ export function createEntity(name) {
|
|||||||
return request('GET', base, { query: { ...query, sort, limit } });
|
return request('GET', base, { query: { ...query, sort, limit } });
|
||||||
},
|
},
|
||||||
|
|
||||||
async get(id) {
|
async get(id: string) {
|
||||||
return request('GET', `${base}/${encodeURIComponent(id)}`);
|
return request('GET', `${base}/${encodeURIComponent(id)}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async create(data) {
|
async create(data: any) {
|
||||||
return request('POST', base, { body: data });
|
return request('POST', base, { body: data });
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(id, data) {
|
async update(id: string, data: any) {
|
||||||
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
|
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id) {
|
async delete(id: string) {
|
||||||
return request('DELETE', `${base}/${encodeURIComponent(id)}`);
|
return request('DELETE', `${base}/${encodeURIComponent(id)}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -304,8 +343,8 @@ export function createEntity(name) {
|
|||||||
* identical — the first rejection stops the run and the records before it
|
* identical — the first rejection stops the run and the records before it
|
||||||
* are already written.
|
* are already written.
|
||||||
*/
|
*/
|
||||||
async bulkCreate(records = []) {
|
async bulkCreate(records: any[] = []) {
|
||||||
const created = [];
|
const created: any[] = [];
|
||||||
for (const record of records) created.push(await this.create(record));
|
for (const record of records) created.push(await this.create(record));
|
||||||
return created;
|
return created;
|
||||||
},
|
},
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { SHIFT_RECORDS } from './attendanceSeed';
|
import { SHIFT_RECORDS } from './attendanceSeed';
|
||||||
import { DEMO_USER } from './demoUser';
|
import { DEMO_USER, EMPLOYER_USER } from './demoUser';
|
||||||
|
|
||||||
const iso = (date) => new Date(`${date}T09:00:00.000Z`).toISOString();
|
const iso = (date: string) => new Date(`${date}T09:00:00.000Z`).toISOString();
|
||||||
|
|
||||||
/* ── Reference data ────────────────────────────────────────────────────── */
|
/* ── Reference data ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -347,7 +347,7 @@ const JOB_POSTINGS = [
|
|||||||
|
|
||||||
/* ── Applications ──────────────────────────────────────────────────────── */
|
/* ── Applications ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const breakdown = (over) => ({
|
const breakdown = (over?: Record<string, number>) => ({
|
||||||
experience: 0, english: 0, reliability: 0, certifications: 0, availability: 0,
|
experience: 0, english: 0, reliability: 0, certifications: 0, availability: 0,
|
||||||
personality: 0, culture_fit: 0, communication_style: 0, attendance_expectations: 0,
|
personality: 0, culture_fit: 0, communication_style: 0, attendance_expectations: 0,
|
||||||
physical_requirements: 0, leadership_expectations: 0, job_related_answers: 0,
|
physical_requirements: 0, leadership_expectations: 0, job_related_answers: 0,
|
||||||
@@ -356,13 +356,13 @@ const breakdown = (over) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** An applicant who has not been screened yet — every AI field stays empty. */
|
/** An applicant who has not been screened yet — every AI field stays empty. */
|
||||||
const unscreened = (over) => ({
|
const unscreened = (over?: Record<string, any>) => ({
|
||||||
status: 'applied',
|
status: 'applied',
|
||||||
ai_score: 0,
|
ai_score: 0,
|
||||||
certifications: [],
|
certifications: [] as string[],
|
||||||
availability: [],
|
availability: [] as string[],
|
||||||
skills: [],
|
skills: [] as string[],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
created_date: iso('2026-08-04'),
|
created_date: iso('2026-08-04'),
|
||||||
updated_date: iso('2026-08-04'),
|
updated_date: iso('2026-08-04'),
|
||||||
@@ -428,7 +428,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['ServSafe', 'CPR / First Aid'],
|
certifications: ['ServSafe', 'CPR / First Aid'],
|
||||||
availability: ['Weekdays', 'Weekends', 'Evenings'],
|
availability: ['Weekdays', 'Weekends', 'Evenings'],
|
||||||
skills: ['Menu design', 'Brigade leadership', 'Food costing', 'Plated dinners', 'Offsite catering'],
|
skills: ['Menu design', 'Brigade leadership', 'Food costing', 'Plated dinners', 'Offsite catering'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.9,
|
client_rating: 4.9,
|
||||||
professional_summary:
|
professional_summary:
|
||||||
'Executive chef with 12 years across hotel banquets and high-end offsite catering. Has run brigades of 20+ and holds plate standards at 400 covers.',
|
'Executive chef with 12 years across hotel banquets and high-end offsite catering. Has run brigades of 20+ and holds plate standards at 400 covers.',
|
||||||
@@ -480,7 +480,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Guard Card', 'CPR / First Aid'],
|
certifications: ['Guard Card', 'CPR / First Aid'],
|
||||||
availability: ['Weekends', 'Evenings', 'Overnight'],
|
availability: ['Weekends', 'Evenings', 'Overnight'],
|
||||||
skills: ['Crowd management', 'De-escalation', 'Incident reporting', 'Access control'],
|
skills: ['Crowd management', 'De-escalation', 'Incident reporting', 'Access control'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.8,
|
client_rating: 4.8,
|
||||||
professional_summary:
|
professional_summary:
|
||||||
'Licensed event security officer with five years across concerts and corporate activations. Known for defusing situations before they escalate.',
|
'Licensed event security officer with five years across concerts and corporate activations. Known for defusing situations before they escalate.',
|
||||||
@@ -514,7 +514,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Guard Card'],
|
certifications: ['Guard Card'],
|
||||||
availability: ['Weekends', 'Evenings'],
|
availability: ['Weekends', 'Evenings'],
|
||||||
skills: ['Access control', 'Incident reporting'],
|
skills: ['Access control', 'Incident reporting'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
professional_summary: 'Venue security officer transitioning into private event work.',
|
professional_summary: 'Venue security officer transitioning into private event work.',
|
||||||
job_posting_id: 'job_security',
|
job_posting_id: 'job_security',
|
||||||
@@ -562,7 +562,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['RBS Alcohol Server', 'TIPS Certified'],
|
certifications: ['RBS Alcohol Server', 'TIPS Certified'],
|
||||||
availability: ['Weekdays', 'Evenings'],
|
availability: ['Weekdays', 'Evenings'],
|
||||||
skills: ['Batch cocktails', 'Craft cocktails', 'Bar setup', 'Inventory control'],
|
skills: ['Batch cocktails', 'Craft cocktails', 'Bar setup', 'Inventory control'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.7,
|
client_rating: 4.7,
|
||||||
professional_summary:
|
professional_summary:
|
||||||
'Corporate event bartender with six years on Bay Area tech campuses. Builds batched programs that hold quality at volume.',
|
'Corporate event bartender with six years on Bay Area tech campuses. Builds batched programs that hold quality at volume.',
|
||||||
@@ -605,7 +605,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Food Handler Card'],
|
certifications: ['Food Handler Card'],
|
||||||
availability: ['Weekends', 'Evenings'],
|
availability: ['Weekends', 'Evenings'],
|
||||||
skills: ['Synchronized service', 'Tray service', 'Wine service', 'Guest relations'],
|
skills: ['Synchronized service', 'Tray service', 'Wine service', 'Guest relations'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.6,
|
client_rating: 4.6,
|
||||||
professional_summary:
|
professional_summary:
|
||||||
'Fine dining event server with four years of coursed banquet service and wine knowledge.',
|
'Fine dining event server with four years of coursed banquet service and wine knowledge.',
|
||||||
@@ -639,7 +639,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Food Handler Card'],
|
certifications: ['Food Handler Card'],
|
||||||
availability: ['Weekends'],
|
availability: ['Weekends'],
|
||||||
skills: ['Banquet service', 'Tray service', 'Table resets'],
|
skills: ['Banquet service', 'Tray service', 'Table resets'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.4,
|
client_rating: 4.4,
|
||||||
professional_summary: 'Banquet server with three years of seated dinner service.',
|
professional_summary: 'Banquet server with three years of seated dinner service.',
|
||||||
selfie_url: 'https://i.pravatar.cc/240?img=44',
|
selfie_url: 'https://i.pravatar.cc/240?img=44',
|
||||||
@@ -673,7 +673,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: [],
|
certifications: [],
|
||||||
availability: ['Weekends', 'Evenings'],
|
availability: ['Weekends', 'Evenings'],
|
||||||
skills: ['Table service'],
|
skills: ['Table service'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
professional_summary: 'Restaurant server moving into event work.',
|
professional_summary: 'Restaurant server moving into event work.',
|
||||||
job_posting_id: 'job_server_fine',
|
job_posting_id: 'job_server_fine',
|
||||||
@@ -695,7 +695,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['TIPS Certified'],
|
certifications: ['TIPS Certified'],
|
||||||
availability: ['Weekends', 'Evenings'],
|
availability: ['Weekends', 'Evenings'],
|
||||||
skills: ['Classic cocktails', 'Bar setup', 'Speed pouring'],
|
skills: ['Classic cocktails', 'Bar setup', 'Speed pouring'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.2,
|
client_rating: 4.2,
|
||||||
professional_summary: 'Event bartender with three years of high-volume weekend service.',
|
professional_summary: 'Event bartender with three years of high-volume weekend service.',
|
||||||
job_posting_id: 'job_bartender',
|
job_posting_id: 'job_bartender',
|
||||||
@@ -728,7 +728,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['TIPS Certified'],
|
certifications: ['TIPS Certified'],
|
||||||
availability: ['Evenings'],
|
availability: ['Evenings'],
|
||||||
skills: ['Bar setup', 'Wine service'],
|
skills: ['Bar setup', 'Wine service'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.0,
|
client_rating: 4.0,
|
||||||
professional_summary: 'Barback stepping up to bartending at private events.',
|
professional_summary: 'Barback stepping up to bartending at private events.',
|
||||||
job_posting_id: 'job_bartender',
|
job_posting_id: 'job_bartender',
|
||||||
@@ -761,7 +761,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: [],
|
certifications: [],
|
||||||
availability: ['Weekends'],
|
availability: ['Weekends'],
|
||||||
skills: [],
|
skills: [],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
professional_summary: 'Looking for a first hospitality role.',
|
professional_summary: 'Looking for a first hospitality role.',
|
||||||
job_posting_id: 'job_bartender',
|
job_posting_id: 'job_bartender',
|
||||||
@@ -795,7 +795,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Forklift Operator'],
|
certifications: ['Forklift Operator'],
|
||||||
availability: ['Weekdays', 'Early mornings'],
|
availability: ['Weekdays', 'Early mornings'],
|
||||||
skills: ['Order picking', 'Scanner operation', 'Pallet staging'],
|
skills: ['Order picking', 'Scanner operation', 'Pallet staging'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 3.9,
|
client_rating: 3.9,
|
||||||
professional_summary: 'Warehouse picker with forklift certification.',
|
professional_summary: 'Warehouse picker with forklift certification.',
|
||||||
job_posting_id: 'job_picker',
|
job_posting_id: 'job_picker',
|
||||||
@@ -827,7 +827,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: [],
|
certifications: [],
|
||||||
availability: ['Weekdays'],
|
availability: ['Weekdays'],
|
||||||
skills: ['Order picking'],
|
skills: ['Order picking'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
professional_summary: 'Fulfillment associate seeking steady warehouse shifts.',
|
professional_summary: 'Fulfillment associate seeking steady warehouse shifts.',
|
||||||
job_posting_id: 'job_picker',
|
job_posting_id: 'job_picker',
|
||||||
@@ -854,7 +854,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Food Handler Card'],
|
certifications: ['Food Handler Card'],
|
||||||
availability: ['Weekdays', 'Weekends'],
|
availability: ['Weekdays', 'Weekends'],
|
||||||
skills: ['Banquet leadership', 'BEO reading', 'Floor coordination'],
|
skills: ['Banquet leadership', 'BEO reading', 'Floor coordination'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
professional_summary: 'Banquet server with lead-shift experience in hotel events.',
|
professional_summary: 'Banquet server with lead-shift experience in hotel events.',
|
||||||
job_posting_id: 'job_banquet',
|
job_posting_id: 'job_banquet',
|
||||||
@@ -874,7 +874,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Food Handler Card'],
|
certifications: ['Food Handler Card'],
|
||||||
availability: ['Weekdays', 'Weekends', 'Evenings'],
|
availability: ['Weekdays', 'Weekends', 'Evenings'],
|
||||||
skills: ['Floor leadership', 'Client relations', 'Event reporting'],
|
skills: ['Floor leadership', 'Client relations', 'Event reporting'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
professional_summary: 'Hotel banquet captain with six years directing event floors.',
|
professional_summary: 'Hotel banquet captain with six years directing event floors.',
|
||||||
job_posting_id: 'job_banquet',
|
job_posting_id: 'job_banquet',
|
||||||
@@ -900,7 +900,7 @@ const JOB_APPLICATIONS = [
|
|||||||
|
|
||||||
/* ── AI interviews ─────────────────────────────────────────────────────── */
|
/* ── AI interviews ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const transcript = (candidate, role) => [
|
const transcript = (candidate: string, role: string) => [
|
||||||
{ role: 'assistant', content: `Hi ${candidate.split(' ')[0]} — thanks for making time. Tell me about the busiest ${role} shift you've worked and how you handled it.`, timestamp: iso('2026-08-04') },
|
{ role: 'assistant', content: `Hi ${candidate.split(' ')[0]} — thanks for making time. Tell me about the busiest ${role} shift you've worked and how you handled it.`, timestamp: iso('2026-08-04') },
|
||||||
{ role: 'user', content: 'We were short two people on a 300-guest event. I re-sectioned the floor, moved the strongest server to the head tables, and pulled the rest into a runner rotation so nothing sat under the lamps.', timestamp: iso('2026-08-04'), response_time_seconds: 11 },
|
{ role: 'user', content: 'We were short two people on a 300-guest event. I re-sectioned the floor, moved the strongest server to the head tables, and pulled the rest into a runner rotation so nothing sat under the lamps.', timestamp: iso('2026-08-04'), response_time_seconds: 11 },
|
||||||
{ role: 'assistant', content: 'What broke first, and what did you do the moment you noticed?', timestamp: iso('2026-08-04') },
|
{ role: 'assistant', content: 'What broke first, and what did you do the moment you noticed?', timestamp: iso('2026-08-04') },
|
||||||
@@ -919,7 +919,7 @@ const AI_INTERVIEWS = [
|
|||||||
verdict: 'maybe',
|
verdict: 'maybe',
|
||||||
hire_recommendation: 'Worth a trial shift on a coursed dinner before committing to the season.',
|
hire_recommendation: 'Worth a trial shift on a coursed dinner before committing to the season.',
|
||||||
integrity_score: 100,
|
integrity_score: 100,
|
||||||
ai_flags: [],
|
ai_flags: [] as string[],
|
||||||
category_scores: {
|
category_scores: {
|
||||||
communication: 78, confidence: 71, experience_relevance: 74, culture_fit: 76,
|
communication: 78, confidence: 71, experience_relevance: 74, culture_fit: 76,
|
||||||
problem_solving: 69, personality: 77, communication_style: 76,
|
problem_solving: 69, personality: 77, communication_style: 76,
|
||||||
@@ -947,11 +947,11 @@ const AI_INTERVIEWS = [
|
|||||||
verdict: 'maybe',
|
verdict: 'maybe',
|
||||||
hire_recommendation: '',
|
hire_recommendation: '',
|
||||||
integrity_score: 100,
|
integrity_score: 100,
|
||||||
ai_flags: [],
|
ai_flags: [] as string[],
|
||||||
category_scores: {},
|
category_scores: {},
|
||||||
strengths: [],
|
strengths: [] as string[],
|
||||||
concerns: [],
|
concerns: [] as string[],
|
||||||
best_fit_roles: [],
|
best_fit_roles: [] as string[],
|
||||||
summary: '',
|
summary: '',
|
||||||
reasoning: '',
|
reasoning: '',
|
||||||
created_date: iso('2026-08-05'),
|
created_date: iso('2026-08-05'),
|
||||||
@@ -967,11 +967,11 @@ const AI_INTERVIEWS = [
|
|||||||
verdict: 'maybe',
|
verdict: 'maybe',
|
||||||
hire_recommendation: '',
|
hire_recommendation: '',
|
||||||
integrity_score: 100,
|
integrity_score: 100,
|
||||||
ai_flags: [],
|
ai_flags: [] as string[],
|
||||||
category_scores: {},
|
category_scores: {},
|
||||||
strengths: [],
|
strengths: [] as string[],
|
||||||
concerns: [],
|
concerns: [] as string[],
|
||||||
best_fit_roles: [],
|
best_fit_roles: [] as string[],
|
||||||
summary: '',
|
summary: '',
|
||||||
reasoning: '',
|
reasoning: '',
|
||||||
created_date: iso('2026-08-05'),
|
created_date: iso('2026-08-05'),
|
||||||
@@ -987,11 +987,11 @@ const AI_INTERVIEWS = [
|
|||||||
verdict: 'maybe',
|
verdict: 'maybe',
|
||||||
hire_recommendation: '',
|
hire_recommendation: '',
|
||||||
integrity_score: 100,
|
integrity_score: 100,
|
||||||
ai_flags: [],
|
ai_flags: [] as string[],
|
||||||
category_scores: {},
|
category_scores: {},
|
||||||
strengths: [],
|
strengths: [] as string[],
|
||||||
concerns: [],
|
concerns: [] as string[],
|
||||||
best_fit_roles: [],
|
best_fit_roles: [] as string[],
|
||||||
summary: '',
|
summary: '',
|
||||||
reasoning: '',
|
reasoning: '',
|
||||||
created_date: iso('2026-08-03'),
|
created_date: iso('2026-08-03'),
|
||||||
@@ -1015,7 +1015,7 @@ const STAFF = [
|
|||||||
ai_score: 92,
|
ai_score: 92,
|
||||||
status: 'onboarding',
|
status: 'onboarding',
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
endorsed_skills: [],
|
endorsed_skills: [] as string[],
|
||||||
created_date: iso('2026-07-25'),
|
created_date: iso('2026-07-25'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1031,7 +1031,7 @@ const STAFF = [
|
|||||||
ai_score: 94,
|
ai_score: 94,
|
||||||
status: 'onboarding',
|
status: 'onboarding',
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
endorsed_skills: [],
|
endorsed_skills: [] as string[],
|
||||||
created_date: iso('2026-07-25'),
|
created_date: iso('2026-07-25'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1047,7 +1047,7 @@ const STAFF = [
|
|||||||
ai_score: 97,
|
ai_score: 97,
|
||||||
status: 'onboarding',
|
status: 'onboarding',
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
endorsed_skills: [],
|
endorsed_skills: [] as string[],
|
||||||
created_date: iso('2026-07-25'),
|
created_date: iso('2026-07-25'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -1085,7 +1085,7 @@ const COURSES = [
|
|||||||
{ criterion: 'Protects the table experience', weight: 30 },
|
{ criterion: 'Protects the table experience', weight: 30 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
|
||||||
quiz: [
|
quiz: [
|
||||||
{
|
{
|
||||||
question: 'A guest flags an allergy mid-service. What happens first?',
|
question: 'A guest flags an allergy mid-service. What happens first?',
|
||||||
@@ -1127,7 +1127,7 @@ const COURSES = [
|
|||||||
{ criterion: 'Escalates appropriately', weight: 30 },
|
{ criterion: 'Escalates appropriately', weight: 30 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
|
||||||
quiz: [
|
quiz: [
|
||||||
{
|
{
|
||||||
question: 'A guest shows clear signs of intoxication. What do you do?',
|
question: 'A guest shows clear signs of intoxication. What do you do?',
|
||||||
@@ -1151,7 +1151,7 @@ const COURSES = [
|
|||||||
proof_skill: 'Hazard Identification',
|
proof_skill: 'Hazard Identification',
|
||||||
skill_id: 'food_safety',
|
skill_id: 'food_safety',
|
||||||
target_level: 'beginner',
|
target_level: 'beginner',
|
||||||
required_level: null,
|
required_level: null as string | null,
|
||||||
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
|
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
|
||||||
verification_criteria: ['Identifies temperature violations', 'Spots cross-contamination risk', 'Notes obstruction hazards'],
|
verification_criteria: ['Identifies temperature violations', 'Spots cross-contamination risk', 'Notes obstruction hazards'],
|
||||||
challenge: {
|
challenge: {
|
||||||
@@ -1164,7 +1164,7 @@ const COURSES = [
|
|||||||
{ criterion: 'Notes obstruction hazards', weight: 20 },
|
{ criterion: 'Notes obstruction hazards', weight: 20 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
|
||||||
quiz: [
|
quiz: [
|
||||||
{
|
{
|
||||||
question: 'What is the danger zone for cold-held food?',
|
question: 'What is the danger zone for cold-held food?',
|
||||||
@@ -1201,7 +1201,7 @@ const COURSES = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
unlock_requirements: { min_shifts: 5, min_reliability: 70, required_badges: ['Service Fundamentals'] },
|
unlock_requirements: { min_shifts: 5, min_reliability: 70, required_badges: ['Service Fundamentals'] },
|
||||||
quiz: [],
|
quiz: [] as unknown[],
|
||||||
pass_score: 80,
|
pass_score: 80,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
created_date: iso('2026-06-16'),
|
created_date: iso('2026-06-16'),
|
||||||
@@ -1222,7 +1222,17 @@ const COURSES = [
|
|||||||
* what Owliver checks. `required_level` is the rung below, which is what makes
|
* what Owliver checks. `required_level` is the rung below, which is what makes
|
||||||
* the ladder a ladder rather than a menu.
|
* the ladder a ladder rather than a menu.
|
||||||
*/
|
*/
|
||||||
const MODULE_TABLE = [
|
/**
|
||||||
|
* One training module per row: skill, rung, title, minutes, challenge type,
|
||||||
|
* description, and the criteria it is marked against.
|
||||||
|
*
|
||||||
|
* Written out as a tuple because a bare array of mixed literals infers as an
|
||||||
|
* array of the UNION of its column types, so destructuring a row below gives
|
||||||
|
* every field `string | number | string[]` — and `SKILL_CATEGORY[skill_id]`
|
||||||
|
* then refuses a key that might be an array. The tuple says which column is
|
||||||
|
* which, which is what the reader already assumes.
|
||||||
|
*/
|
||||||
|
const MODULE_TABLE: [string, string, string, number, string, string, string[]][] = [
|
||||||
/* Server — the full four-rung ladder. */
|
/* Server — the full four-rung ladder. */
|
||||||
['server', 'beginner', 'Server Fundamentals', 18, 'roleplay', 'Sequence of service, tray discipline, and the first ninety seconds at a table.', ['Greets and sets expectations', 'Follows the service sequence', 'Handles the tray safely']],
|
['server', 'beginner', 'Server Fundamentals', 18, 'roleplay', 'Sequence of service, tray discipline, and the first ninety seconds at a table.', ['Greets and sets expectations', 'Follows the service sequence', 'Handles the tray safely']],
|
||||||
['server', 'beginner', 'Workplace Safety', 14, 'photo_identify', 'Spot the hazard before it becomes an incident — spills, traffic, hot pass.', ['Identifies floor hazards', 'Spots obstruction risks', 'Knows when to stop service']],
|
['server', 'beginner', 'Workplace Safety', 14, 'photo_identify', 'Spot the hazard before it becomes an incident — spills, traffic, hot pass.', ['Identifies floor hazards', 'Spots obstruction risks', 'Knows when to stop service']],
|
||||||
@@ -1283,7 +1293,7 @@ const MODULE_TABLE = [
|
|||||||
['food_safety', 'advanced', 'Incident Response & Reporting', 18, 'roleplay', 'A suspected foodborne incident, in the hour it matters.', ['Isolates the product', 'Documents accurately', 'Notifies the right people']],
|
['food_safety', 'advanced', 'Incident Response & Reporting', 18, 'roleplay', 'A suspected foodborne incident, in the hour it matters.', ['Isolates the product', 'Documents accurately', 'Notifies the right people']],
|
||||||
];
|
];
|
||||||
|
|
||||||
const SKILL_CATEGORY = {
|
const SKILL_CATEGORY: Record<string, string> = {
|
||||||
server: 'Service',
|
server: 'Service',
|
||||||
bartending: 'Bar',
|
bartending: 'Bar',
|
||||||
leadership: 'Leadership',
|
leadership: 'Leadership',
|
||||||
@@ -1294,20 +1304,20 @@ const SKILL_CATEGORY = {
|
|||||||
const LEVEL_ORDER = ['beginner', 'intermediate', 'advanced', 'expert'];
|
const LEVEL_ORDER = ['beginner', 'intermediate', 'advanced', 'expert'];
|
||||||
|
|
||||||
/** Level → the rung below it, which is what a module requires before it opens. */
|
/** Level → the rung below it, which is what a module requires before it opens. */
|
||||||
const requiredLevelFor = (level) => LEVEL_ORDER[LEVEL_ORDER.indexOf(level) - 1] || null;
|
const requiredLevelFor = (level: string) => LEVEL_ORDER[LEVEL_ORDER.indexOf(level) - 1] || null;
|
||||||
|
|
||||||
/** Difficulty is the existing field; level drives it so the two never disagree. */
|
/** Difficulty is the existing field; level drives it so the two never disagree. */
|
||||||
const DIFFICULTY_FOR = { beginner: 'beginner', intermediate: 'intermediate', advanced: 'advanced', expert: 'advanced' };
|
const DIFFICULTY_FOR: Record<string, string> = { beginner: 'beginner', intermediate: 'intermediate', advanced: 'advanced', expert: 'advanced' };
|
||||||
|
|
||||||
const XP_FOR = { beginner: 100, intermediate: 150, advanced: 220, expert: 300 };
|
const XP_FOR: Record<string, number> = { beginner: 100, intermediate: 150, advanced: 220, expert: 300 };
|
||||||
|
|
||||||
const PROMPT_FOR = {
|
const PROMPT_FOR: Record<string, (title: string) => string> = {
|
||||||
roleplay: (title) => `Work through a ${title.toLowerCase()} scenario with Owliver, start to finish.`,
|
roleplay: (title) => `Work through a ${title.toLowerCase()} scenario with Owliver, start to finish.`,
|
||||||
video: (title) => `Record yourself demonstrating ${title.toLowerCase()} as you would on a live shift.`,
|
video: (title) => `Record yourself demonstrating ${title.toLowerCase()} as you would on a live shift.`,
|
||||||
photo_identify: (title) => `Mark everything a ${title.toLowerCase()} check should catch in this photo.`,
|
photo_identify: (title) => `Mark everything a ${title.toLowerCase()} check should catch in this photo.`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
const slug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
||||||
|
|
||||||
const TRAINING_MODULES = MODULE_TABLE.map(
|
const TRAINING_MODULES = MODULE_TABLE.map(
|
||||||
([skill_id, target_level, title, minutes, type, description, criteria], i) => ({
|
([skill_id, target_level, title, minutes, type, description, criteria], i) => ({
|
||||||
@@ -1331,8 +1341,8 @@ const TRAINING_MODULES = MODULE_TABLE.map(
|
|||||||
ai_persona: type === 'roleplay' ? 'A guest or teammate in a live service situation.' : undefined,
|
ai_persona: type === 'roleplay' ? 'A guest or teammate in a live service situation.' : undefined,
|
||||||
rubric: (Array.isArray(criteria) ? criteria : []).map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
|
rubric: (Array.isArray(criteria) ? criteria : []).map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
|
||||||
},
|
},
|
||||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
|
||||||
quiz: [],
|
quiz: [] as unknown[],
|
||||||
pass_score: 70,
|
pass_score: 70,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
created_date: iso('2026-06-20'),
|
created_date: iso('2026-06-20'),
|
||||||
@@ -1344,7 +1354,7 @@ const TRAINING_MODULES = MODULE_TABLE.map(
|
|||||||
const COURSE_LIBRARY = [...COURSES, ...TRAINING_MODULES];
|
const COURSE_LIBRARY = [...COURSES, ...TRAINING_MODULES];
|
||||||
|
|
||||||
/** A module id from its skill and title, so profiles read like the ladder does. */
|
/** A module id from its skill and title, so profiles read like the ladder does. */
|
||||||
const mod = (skill, title) => `mod_${skill}_${slug(title)}`;
|
const mod = (skill: string, title: string) => `mod_${skill}_${slug(title)}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Completed training on a worker profile.
|
* Completed training on a worker profile.
|
||||||
@@ -1352,7 +1362,7 @@ const mod = (skill, title) => `mod_${skill}_${slug(title)}`;
|
|||||||
* Titles are looked up rather than repeated, so a renamed module cannot leave a
|
* Titles are looked up rather than repeated, so a renamed module cannot leave a
|
||||||
* profile claiming a course that no longer goes by that name.
|
* profile claiming a course that no longer goes by that name.
|
||||||
*/
|
*/
|
||||||
const completions = (ids, date, score = 88) =>
|
const completions = (ids: string[], date: string, score = 88) =>
|
||||||
ids.map((id) => ({
|
ids.map((id) => ({
|
||||||
course_id: id,
|
course_id: id,
|
||||||
title: COURSE_LIBRARY.find((c) => c.id === id)?.title || id,
|
title: COURSE_LIBRARY.find((c) => c.id === id)?.title || id,
|
||||||
@@ -1525,7 +1535,7 @@ const WORKER_PROFILES = [
|
|||||||
earned_badges: [
|
earned_badges: [
|
||||||
{ name: 'Service Fundamentals', level: 'bronze', course_id: 'course_service_basics', earned_date: iso('2026-07-28') },
|
{ name: 'Service Fundamentals', level: 'bronze', course_id: 'course_service_basics', earned_date: iso('2026-07-28') },
|
||||||
],
|
],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 6,
|
shifts_completed: 6,
|
||||||
attendance_score: 92,
|
attendance_score: 92,
|
||||||
performance_score: 0,
|
performance_score: 0,
|
||||||
@@ -1595,8 +1605,8 @@ const WORKER_PROFILES = [
|
|||||||
'2026-08-02',
|
'2026-08-02',
|
||||||
89
|
89
|
||||||
),
|
),
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 38,
|
shifts_completed: 38,
|
||||||
attendance_score: 96,
|
attendance_score: 96,
|
||||||
performance_score: 88,
|
performance_score: 88,
|
||||||
@@ -1643,8 +1653,8 @@ const WORKER_PROFILES = [
|
|||||||
'2026-07-30',
|
'2026-07-30',
|
||||||
85
|
85
|
||||||
),
|
),
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 21,
|
shifts_completed: 21,
|
||||||
attendance_score: 93,
|
attendance_score: 93,
|
||||||
performance_score: 81,
|
performance_score: 81,
|
||||||
@@ -1691,8 +1701,8 @@ const WORKER_PROFILES = [
|
|||||||
'2026-08-05',
|
'2026-08-05',
|
||||||
76
|
76
|
||||||
),
|
),
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 4,
|
shifts_completed: 4,
|
||||||
attendance_score: 100,
|
attendance_score: 100,
|
||||||
performance_score: 0,
|
performance_score: 0,
|
||||||
@@ -1735,8 +1745,8 @@ const WORKER_PROFILES = [
|
|||||||
},
|
},
|
||||||
xp: 0,
|
xp: 0,
|
||||||
completed_courses: [],
|
completed_courses: [],
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 0,
|
shifts_completed: 0,
|
||||||
attendance_score: 100,
|
attendance_score: 100,
|
||||||
performance_score: 0,
|
performance_score: 0,
|
||||||
@@ -1779,8 +1789,8 @@ const WORKER_PROFILES = [
|
|||||||
},
|
},
|
||||||
xp: 0,
|
xp: 0,
|
||||||
completed_courses: [],
|
completed_courses: [],
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 0,
|
shifts_completed: 0,
|
||||||
attendance_score: 100,
|
attendance_score: 100,
|
||||||
performance_score: 0,
|
performance_score: 0,
|
||||||
@@ -1823,8 +1833,8 @@ const WORKER_PROFILES = [
|
|||||||
},
|
},
|
||||||
xp: 0,
|
xp: 0,
|
||||||
completed_courses: [],
|
completed_courses: [],
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 0,
|
shifts_completed: 0,
|
||||||
attendance_score: 100,
|
attendance_score: 100,
|
||||||
performance_score: 0,
|
performance_score: 0,
|
||||||
@@ -1867,8 +1877,8 @@ const WORKER_PROFILES = [
|
|||||||
},
|
},
|
||||||
xp: 0,
|
xp: 0,
|
||||||
completed_courses: [],
|
completed_courses: [],
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 0,
|
shifts_completed: 0,
|
||||||
attendance_score: 100,
|
attendance_score: 100,
|
||||||
performance_score: 0,
|
performance_score: 0,
|
||||||
@@ -1980,8 +1990,12 @@ const USER_ACTIVITY = ACTIVITY_EVENTS.map(([event_type, user_email, user_name, a
|
|||||||
* candidate, interview and shift record below — into the production bundle.
|
* candidate, interview and shift record below — into the production bundle.
|
||||||
* The fixtures here are for the test scripts, and they still read
|
* The fixtures here are for the test scripts, and they still read
|
||||||
* `seedData.User` and `DEMO_USER` from this module, so both keep working.
|
* `seedData.User` and `DEMO_USER` from this module, so both keep working.
|
||||||
|
*
|
||||||
|
* `EMPLOYER_USER` travels the same way. It is seeded, never rendered: the
|
||||||
|
* backend writes both accounts so the employer console has somebody to sign
|
||||||
|
* in as, and nothing in the running app imports it.
|
||||||
*/
|
*/
|
||||||
export { DEMO_USER } from './demoUser';
|
export { DEMO_USER, EMPLOYER_USER } from './demoUser';
|
||||||
|
|
||||||
/* ── Assignments ───────────────────────────────────────────────────────────
|
/* ── Assignments ───────────────────────────────────────────────────────────
|
||||||
Who is on which position, and until when — the record that turns a hire into
|
Who is on which position, and until when — the record that turns a hire into
|
||||||
@@ -2014,7 +2028,7 @@ const ASSIGNMENTS = [
|
|||||||
worker_email: 'marco.rivera@email.com',
|
worker_email: 'marco.rivera@email.com',
|
||||||
worker_name: 'Marco Rivera',
|
worker_name: 'Marco Rivera',
|
||||||
starts_at: iso('2026-07-01'),
|
starts_at: iso('2026-07-01'),
|
||||||
ends_at: null,
|
ends_at: null as string | null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
source: 'seed',
|
source: 'seed',
|
||||||
match_score: 92,
|
match_score: 92,
|
||||||
@@ -2041,5 +2055,5 @@ export const seedData = {
|
|||||||
Assignment: ASSIGNMENTS,
|
Assignment: ASSIGNMENTS,
|
||||||
ShiftRecord: SHIFT_RECORDS,
|
ShiftRecord: SHIFT_RECORDS,
|
||||||
Evidence: EVIDENCE,
|
Evidence: EVIDENCE,
|
||||||
User: [DEMO_USER],
|
User: [DEMO_USER, EMPLOYER_USER],
|
||||||
};
|
};
|
||||||
@@ -21,7 +21,12 @@ const DefaultFallback = () => (
|
|||||||
* The attempted path travels in location state so signing in returns the
|
* The attempted path travels in location state so signing in returns the
|
||||||
* visitor to where they were going.
|
* visitor to where they were going.
|
||||||
*/
|
*/
|
||||||
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }) {
|
interface ProtectedRouteProps {
|
||||||
|
fallback?: any;
|
||||||
|
unauthenticatedElement?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }: ProtectedRouteProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
||||||
const signedOut = unauthenticatedElement ?? (
|
const signedOut = unauthenticatedElement ?? (
|
||||||
@@ -17,8 +17,17 @@ import { cn } from '@/lib/utils';
|
|||||||
* Controls live in the page rather than in the global chrome, which is why the
|
* Controls live in the page rather than in the global chrome, which is why the
|
||||||
* Admin header only needs one 56px bar.
|
* Admin header only needs one 56px bar.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
interface AdminPageProps {
|
||||||
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
meta?: any;
|
||||||
|
actions?: any;
|
||||||
|
tabs?: any;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }: AdminPageProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-6', className)}>
|
<div className={cn('space-y-6', className)}>
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
|
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
|
||||||
@@ -57,8 +66,15 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
|
|||||||
* Sentence-case and small rather than a heavy all-caps overline: on a page with
|
* Sentence-case and small rather than a heavy all-caps overline: on a page with
|
||||||
* four or five sections, loud labels compete with the data they introduce.
|
* four or five sections, loud labels compete with the data they introduce.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
interface SectionTitleProps {
|
||||||
export function SectionTitle({ id, title, meta, action, className }) {
|
id?: any;
|
||||||
|
title?: string;
|
||||||
|
meta?: any;
|
||||||
|
action?: any;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionTitle({ id, title, meta, action, className }: SectionTitleProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
|
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
|
||||||
{/* The count wraps under the section name rather than competing with it
|
{/* The count wraps under the section name rather than competing with it
|
||||||
@@ -91,8 +107,15 @@ export function SectionTitle({ id, title, meta, action, className }) {
|
|||||||
* primary action is pushed to the end, so every management page has the same
|
* primary action is pushed to the end, so every management page has the same
|
||||||
* control geometry.
|
* control geometry.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
interface ToolbarProps {
|
||||||
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }) {
|
search?: any;
|
||||||
|
filters?: any;
|
||||||
|
actions?: any;
|
||||||
|
meta?: any;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }: ToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-2', className)}>
|
<div className={cn('space-y-2', className)}>
|
||||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
import {
|
import {
|
||||||
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
|
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -19,8 +20,10 @@ import { cn } from '@/lib/utils';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
|
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
|
||||||
/** @type {[RegExp, React.ComponentType<any>][]} */
|
/* `[pattern, icon]` pairs. Without the tuple the element widens to the union of
|
||||||
const GLYPHS = [
|
both positions, so `pattern.test` and `<Icon />` both stop working — the same
|
||||||
|
shape as `aiEngine`'s route table. */
|
||||||
|
const GLYPHS: [RegExp, ComponentType<any>][] = [
|
||||||
[/chef|cook|kitchen/i, ChefHat],
|
[/chef|cook|kitchen/i, ChefHat],
|
||||||
[/bartend|bar|mixolog/i, Wine],
|
[/bartend|bar|mixolog/i, Wine],
|
||||||
[/server|wait|host|banquet|concierge/i, ConciergeBell],
|
[/server|wait|host|banquet|concierge/i, ConciergeBell],
|
||||||
@@ -42,8 +45,15 @@ const SIZES = {
|
|||||||
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
|
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @param {any} props */
|
interface RoleGlyphProps {
|
||||||
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
|
category?: any;
|
||||||
|
title?: string;
|
||||||
|
size?: any;
|
||||||
|
aiGenerated?: any;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }: RoleGlyphProps) {
|
||||||
const Icon = iconForRole(category, title);
|
const Icon = iconForRole(category, title);
|
||||||
const s = SIZES[size] || SIZES.md;
|
const s = SIZES[size] || SIZES.md;
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
|||||||
* holds a ~380px track on the right of every Admin page, so below `xl` the
|
* holds a ~380px track on the right of every Admin page, so below `xl` the
|
||||||
* remaining width belongs to the editor and the rail becomes a strip above it.
|
* remaining width belongs to the editor and the rail becomes a strip above it.
|
||||||
*/
|
*/
|
||||||
export function Workspace({ rail, children, className }) {
|
export function Workspace({ rail, children, className }: any) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden', className)}>
|
<div className={cn('w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden', className)}>
|
||||||
<div className="border-b border-border/80 bg-surface-subtle/40 px-4 py-3 sm:px-6">
|
<div className="border-b border-border/80 bg-surface-subtle/40 px-4 py-3 sm:px-6">
|
||||||
@@ -64,8 +64,7 @@ export function Workspace({ rail, children, className }) {
|
|||||||
* height without measuring it in JavaScript. Content stays mounted, which is
|
* height without measuring it in JavaScript. Content stays mounted, which is
|
||||||
* what keeps a section addressable by the rail while it is closed.
|
* what keeps a section addressable by the rail while it is closed.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
export function Collapse({ open, children, className }: any) {
|
||||||
export function Collapse({ open, children, className }) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -100,8 +99,7 @@ export function Collapse({ open, children, className }) {
|
|||||||
* Provides clear section navigation and leaf anchor shortcuts across the
|
* Provides clear section navigation and leaf anchor shortcuts across the
|
||||||
* configuration workspace.
|
* configuration workspace.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
export function Rail({ sections, activeId, onSelect }: any) {
|
||||||
export function Rail({ sections, activeId, onSelect }) {
|
|
||||||
const activeSection = sections.find((s) => s.id === activeId);
|
const activeSection = sections.find((s) => s.id === activeId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -181,11 +179,10 @@ export function Rail({ sections, activeId, onSelect }) {
|
|||||||
/**
|
/**
|
||||||
* One section of the document.
|
* One section of the document.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
|
||||||
export function DocSection({
|
export function DocSection({
|
||||||
id, icon: Icon, title, description, meta, open, onToggle, emphasis = false, last = false,
|
id, icon: Icon, title, description, meta, open, onToggle, emphasis = false, last = false,
|
||||||
children,
|
children,
|
||||||
}) {
|
}: any) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={id}
|
||||||
@@ -257,8 +254,7 @@ export function DocSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A labelled field in the document body. */
|
/** A labelled field in the document body. */
|
||||||
/** @param {any} props */
|
export function DocField({ id, label, hint, children, className }: any) {
|
||||||
export function DocField({ id, label, hint, children, className }) {
|
|
||||||
const reactId = React.useId();
|
const reactId = React.useId();
|
||||||
const controlId = id ? `${id}-control` : reactId;
|
const controlId = id ? `${id}-control` : reactId;
|
||||||
|
|
||||||
@@ -270,7 +266,7 @@ export function DocField({ id, label, hint, children, className }) {
|
|||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
{React.isValidElement(children)
|
{React.isValidElement<any>(children)
|
||||||
? React.cloneElement(children, { id: children.props.id || controlId })
|
? React.cloneElement(children, { id: children.props.id || controlId })
|
||||||
: children}
|
: children}
|
||||||
{hint && <p className="text-caption leading-relaxed text-ink-4">{hint}</p>}
|
{hint && <p className="text-caption leading-relaxed text-ink-4">{hint}</p>}
|
||||||
@@ -284,8 +280,7 @@ export function DocField({ id, label, hint, children, className }) {
|
|||||||
* One notch quieter than a section title and one louder than a row, which is
|
* One notch quieter than a section title and one louder than a row, which is
|
||||||
* exactly the level of the thing it names.
|
* exactly the level of the thing it names.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
export function GroupHead({ id, icon: Icon, title, count, action, className }: any) {
|
||||||
export function GroupHead({ id, icon: Icon, title, count, action, className }) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={id}
|
id={id}
|
||||||
@@ -310,8 +305,7 @@ export function GroupHead({ id, icon: Icon, title, count, action, className }) {
|
|||||||
* competing with the section that holds them; as rows they read as a list,
|
* competing with the section that holds them; as rows they read as a list,
|
||||||
* which is what they are.
|
* which is what they are.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }: any) {
|
||||||
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }) {
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={`group/item -mx-2 flex items-start gap-2.5 rounded-lg px-2 py-2
|
className={`group/item -mx-2 flex items-start gap-2.5 rounded-lg px-2 py-2
|
||||||
@@ -352,10 +346,9 @@ export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLa
|
|||||||
* a switch in a button makes one press mean two things. A setting that needs
|
* a switch in a button makes one press mean two things. A setting that needs
|
||||||
* space gets a chevron and opens underneath.
|
* space gets a chevron and opens underneath.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
|
||||||
export function SettingRow({
|
export function SettingRow({
|
||||||
id, icon: Icon, title, value, description, control, children, open, onToggle, last = false,
|
id, icon: Icon, title, value, description, control, children, open, onToggle, last = false,
|
||||||
}) {
|
}: any) {
|
||||||
const expandable = Boolean(children);
|
const expandable = Boolean(children);
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
@@ -412,8 +405,7 @@ export function SettingRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A page an agent covers, or one it could. */
|
/** A page an agent covers, or one it could. */
|
||||||
/** @param {any} props */
|
export function ScopeChip({ active, children, ...props }: any) {
|
||||||
export function ScopeChip({ active, children, ...props }) {
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -443,8 +435,7 @@ export function ScopeChip({ active, children, ...props }) {
|
|||||||
/* ── Identity ────────────────────────────────────────────────────────────── */
|
/* ── Identity ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** The icons an agent may wear, as a picker. */
|
/** The icons an agent may wear, as a picker. */
|
||||||
/** @param {any} props */
|
export function IconPicker({ icons, value, onChange }: any) {
|
||||||
export function IconPicker({ icons, value, onChange }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-5 gap-1.5">
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
{icons.map((icon) => {
|
{icons.map((icon) => {
|
||||||
@@ -486,8 +477,7 @@ export function IconPicker({ icons, value, onChange }) {
|
|||||||
* same three things the switcher renders — icon, name, description — and
|
* same three things the switcher renders — icon, name, description — and
|
||||||
* nothing it does not.
|
* nothing it does not.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
export function AgentPreview({ name, description, icon }: any) {
|
||||||
export function AgentPreview({ name, description, icon }) {
|
|
||||||
const Icon = agentIconFor(icon);
|
const Icon = agentIconFor(icon);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -94,7 +94,6 @@ function useActiveSection(ids) {
|
|||||||
return [active, setActive];
|
return [active, setActive];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
|
||||||
export function AgentConfigure({
|
export function AgentConfigure({
|
||||||
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
|
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
|
||||||
/* Attaching and detaching a skill goes through the caller's existing write
|
/* Attaching and detaching a skill goes through the caller's existing write
|
||||||
@@ -107,7 +106,7 @@ export function AgentConfigure({
|
|||||||
/* The tools this deployment registers, from GET /api/v1/tools. Passed in
|
/* The tools this deployment registers, from GET /api/v1/tools. Passed in
|
||||||
rather than fetched here so this component stays a form over `fields`. */
|
rather than fetched here so this component stays a form over `fields`. */
|
||||||
toolCatalogue = [],
|
toolCatalogue = [],
|
||||||
}) {
|
}: any) {
|
||||||
/* Sections are open by default: this is a document, and one that greets its
|
/* Sections are open by default: this is a document, and one that greets its
|
||||||
author with four closed headers hides the thing they came to write. Closing
|
author with four closed headers hides the thing they came to write. Closing
|
||||||
is for focus, not for the initial reading. */
|
is for focus, not for the initial reading. */
|
||||||
@@ -22,8 +22,7 @@ import { cn } from '@/lib/utils';
|
|||||||
* Real-time usage analytics computed directly from the local telemetry log.
|
* Real-time usage analytics computed directly from the local telemetry log.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @param {any} props */
|
function MetricCard({ icon: Icon, label, value, detail, highlight }: any) {
|
||||||
function MetricCard({ icon: Icon, label, value, detail, highlight }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-2">
|
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -45,8 +44,7 @@ function MetricCard({ icon: Icon, label, value, detail, highlight }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||||
export function AgentInsightsPanel({ agentId, agentName }) {
|
|
||||||
const preferences = usePreferences();
|
const preferences = usePreferences();
|
||||||
|
|
||||||
const [records] = React.useState(() => readHistory());
|
const [records] = React.useState(() => readHistory());
|
||||||
@@ -46,8 +46,7 @@ const CLASSIFICATION_COPY = {
|
|||||||
combined: 'Requires both structured records and knowledge retrieval.',
|
combined: 'Requires both structured records and knowledge retrieval.',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @param {any} props */
|
export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }: any) {
|
||||||
export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }) {
|
|
||||||
const [contextId, setContextId] = React.useState(
|
const [contextId, setContextId] = React.useState(
|
||||||
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|
||||||
|| CONTEXT_OPTIONS[0].contextId
|
|| CONTEXT_OPTIONS[0].contextId
|
||||||
@@ -43,7 +43,6 @@ import { SkillDetails } from './SkillDetails';
|
|||||||
const OWLIVER = 'owliver';
|
const OWLIVER = 'owliver';
|
||||||
const BOARD = 'board';
|
const BOARD = 'board';
|
||||||
|
|
||||||
/** @param {any} props */
|
|
||||||
export function AgentSkillWorkspace({
|
export function AgentSkillWorkspace({
|
||||||
fields,
|
fields,
|
||||||
customSkills = [],
|
customSkills = [],
|
||||||
@@ -55,7 +54,7 @@ export function AgentSkillWorkspace({
|
|||||||
busy = false,
|
busy = false,
|
||||||
pendingId = null,
|
pendingId = null,
|
||||||
dirty = false,
|
dirty = false,
|
||||||
}) {
|
}: any) {
|
||||||
const [kind, setKind] = React.useState(OWLIVER);
|
const [kind, setKind] = React.useState(OWLIVER);
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
const [boardQuery, setBoardQuery] = React.useState('');
|
const [boardQuery, setBoardQuery] = React.useState('');
|
||||||
@@ -394,6 +393,10 @@ export function AgentSkillWorkspace({
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
query={boardQuery}
|
query={boardQuery}
|
||||||
onQueryChange={setBoardQuery}
|
onQueryChange={setBoardQuery}
|
||||||
|
/* Ownership, through the one handler that writes `agent.skills` —
|
||||||
|
the same one the Owliver catalog attaches with. */
|
||||||
|
attachedIds={attachedIds}
|
||||||
|
onAttach={onToggleSkill}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +174,6 @@ function StaticLeaf({ icon: Icon, label, detail = null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
|
||||||
export function AgentTree({
|
export function AgentTree({
|
||||||
agentName,
|
agentName,
|
||||||
agentIcon,
|
agentIcon,
|
||||||
@@ -191,7 +190,7 @@ export function AgentTree({
|
|||||||
onToggleBranch,
|
onToggleBranch,
|
||||||
busy = false,
|
busy = false,
|
||||||
className,
|
className,
|
||||||
}) {
|
}: any) {
|
||||||
const Glyph = agentIconFor(agentIcon);
|
const Glyph = agentIconFor(agentIcon);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { LayoutTemplate, Pencil, Plus, SearchX } from 'lucide-react';
|
import { LayoutTemplate, Minus, Pencil, Plus, SearchX } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
||||||
@@ -9,24 +9,30 @@ import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
|||||||
*
|
*
|
||||||
* The second half of one Skills section — the same heading, a tab away from the
|
* The second half of one Skills section — the same heading, a tab away from the
|
||||||
* Owliver catalog — so nobody has to decide between two top-level destinations
|
* Owliver catalog — so nobody has to decide between two top-level destinations
|
||||||
* ever again. What it is *not* is a second copy of that catalog with the word
|
* ever again.
|
||||||
* Board on it, and the reason is worth stating because it is the one thing here
|
|
||||||
* that could quietly become a lie:
|
|
||||||
*
|
*
|
||||||
* **A Board skill is not carried by an agent.** `SkillSurface` renders a
|
* **Two controls, because there are two different questions.** This card used to
|
||||||
* section from the page it names and the account's `disabledSkills`; it does not
|
* offer only the workspace switch, and said so at length: a Board skill was not
|
||||||
* read `agent.skills` and has no agent in scope. Writing a Board skill id into
|
* carried by an agent, because `SkillSurface` read the page and the account's
|
||||||
* `agent.skills` would therefore record an assignment nothing honours — a card
|
* `disabledSkills` and never looked at `agent.skills`. That was true, and it is
|
||||||
* that says "Added" and a product that behaves identically either way.
|
* not any more — `useSkillSections` now asks `agentPermitsSkill`, so an id
|
||||||
|
* written into `agent.skills` is honoured on the page.
|
||||||
*
|
*
|
||||||
* So the relationship shown is the real one: these are the sections the pages
|
* So both questions are asked here, and neither is dressed up as the other:
|
||||||
* *this agent answers on* will draw, and the control offered is the one that
|
*
|
||||||
* actually governs them — the workspace switch every surface already reads. It
|
* - **Active** is the workspace switch. Off means off for everyone, on every
|
||||||
* is labelled as workspace-wide, because it is.
|
* page, for every agent. It is the account's `disabledSkills`.
|
||||||
|
* - **Add to agent** is ownership. It writes this skill's id into
|
||||||
|
* `agent.skills`, through the same handler the Owliver catalog uses.
|
||||||
|
*
|
||||||
|
* Ownership is opt-in and that is what makes the two safe together: a skill no
|
||||||
|
* agent claims still draws wherever its `pages:` say, exactly as before. The
|
||||||
|
* first agent to claim it is what narrows it — which is why attaching is worth
|
||||||
|
* saying out loud on the card rather than leaving as a silent side effect.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** One Board skill: what it draws, where, and whether it is switched on. */
|
/** One Board skill: what it draws, where, and whether it is switched on. */
|
||||||
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
|
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = false, onAttach = null }) {
|
||||||
const switchId = `board-${entry.id}`;
|
const switchId = `board-${entry.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -110,6 +116,31 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ownership, separate from the workspace switch above it. The button
|
||||||
|
writes `agent.skills` through the same handler the Owliver catalog
|
||||||
|
uses — there is no second assignment path and nothing new stored. */}
|
||||||
|
{onAttach && (
|
||||||
|
<div className="relative z-10 mt-3 flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle px-2.5 py-2">
|
||||||
|
<span className="min-w-0 text-caption text-ink-3">
|
||||||
|
{attached
|
||||||
|
? 'Owned by this agent'
|
||||||
|
: 'Not owned by any agent on this page'}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant={attached ? 'outline' : 'default'}
|
||||||
|
shape="rounded"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onAttach(entry.id)}
|
||||||
|
aria-label={`${attached ? 'Remove' : 'Add'} ${entry.name} ${attached ? 'from' : 'to'} this agent`}
|
||||||
|
>
|
||||||
|
{attached
|
||||||
|
? <><Minus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove</>
|
||||||
|
: <><Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Add to agent</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative z-10 mt-4 flex items-center justify-between gap-2 border-t border-border/60 pt-3 font-medium">
|
<div className="relative z-10 mt-4 flex items-center justify-between gap-2 border-t border-border/60 pt-3 font-medium">
|
||||||
<span className="text-caption text-ink-4">
|
<span className="text-caption text-ink-4">
|
||||||
{entry.surfaces.some((s) => onSurfaces.has(s.id))
|
{entry.surfaces.some((s) => onSurfaces.has(s.id))
|
||||||
@@ -128,10 +159,10 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
|
||||||
export function BoardSkillList({
|
export function BoardSkillList({
|
||||||
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
|
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
|
||||||
}) {
|
attachedIds = null, onAttach = null,
|
||||||
|
}: any) {
|
||||||
const searchId = React.useId();
|
const searchId = React.useId();
|
||||||
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
|
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
|
||||||
|
|
||||||
@@ -147,9 +178,11 @@ export function BoardSkillList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Board skills" className="flex min-w-0 flex-col gap-4">
|
<section aria-label="Board skills" className="flex min-w-0 flex-col gap-4">
|
||||||
<Alert tone="info" title="Board skills belong to a page, not to an agent">
|
<Alert tone="info" title="Two switches, two different questions">
|
||||||
They draw sections on KROW pages and are switched on for the whole workspace —
|
<strong>Active</strong> is workspace-wide: off means off on every page, for every
|
||||||
so a change here affects every agent answering on that page, not just this one.
|
agent. <strong>Add to agent</strong> is ownership — once any agent owns a board
|
||||||
|
skill, it draws only where an owning agent is answering. A skill no agent owns
|
||||||
|
keeps drawing wherever its pages say, as before.
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
@@ -192,6 +225,8 @@ export function BoardSkillList({
|
|||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onSurfaces={onSurfaces}
|
onSurfaces={onSurfaces}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
attached={Boolean(attachedIds?.has(entry.id))}
|
||||||
|
onAttach={onAttach}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -25,9 +25,9 @@ import { glyphForGroup } from './glyphs';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {any} */
|
/** @type {any} */
|
||||||
export const SkillCard = React.memo(/** @param {any} props */ ({
|
export const SkillCard = React.memo(({
|
||||||
entry, enabled, onOpen, onAddAgent, onTest, canTest = false, busy = false, isPending = false,
|
entry, enabled, onOpen, onAddAgent, onTest, canTest = false, busy = false, isPending = false,
|
||||||
}) => {
|
}: any) => {
|
||||||
const Glyph = glyphForGroup(entry.group);
|
const Glyph = glyphForGroup(entry.group);
|
||||||
const samplePrompt = entry.suggestions?.[0]?.label;
|
const samplePrompt = entry.suggestions?.[0]?.label;
|
||||||
|
|
||||||
@@ -23,8 +23,7 @@ import { SkillCard } from './SkillCard';
|
|||||||
* has, at every width, with no media query to get wrong.
|
* has, at every width, with no media query to get wrong.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @param {any} props */
|
function CategoryFilter({ groups, value, onChange, total }: any) {
|
||||||
function CategoryFilter({ groups, value, onChange, total }) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
@@ -60,7 +59,6 @@ function CategoryFilter({ groups, value, onChange, total }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
|
||||||
export function SkillCatalog({
|
export function SkillCatalog({
|
||||||
entries,
|
entries,
|
||||||
groups,
|
groups,
|
||||||
@@ -76,7 +74,7 @@ export function SkillCatalog({
|
|||||||
testableIds,
|
testableIds,
|
||||||
busy = false,
|
busy = false,
|
||||||
pendingId = null,
|
pendingId = null,
|
||||||
}) {
|
}: any) {
|
||||||
const searchId = React.useId();
|
const searchId = React.useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,11 +82,10 @@ function TestState({ test, entryId }) {
|
|||||||
* button needs the same `surface` this renders, and two copies of that would be
|
* button needs the same `surface` this renders, and two copies of that would be
|
||||||
* two answers to "what am I about to enable".
|
* two answers to "what am I about to enable".
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
|
||||||
export function CapabilityBrief({
|
export function CapabilityBrief({
|
||||||
entry, attached, surface, onSurfaceChange, question, onQuestionChange,
|
entry, attached, surface, onSurfaceChange, question, onQuestionChange,
|
||||||
canTest, target, onTest, test, busy = false, showTest = true,
|
canTest, target, onTest, test, busy = false, showTest = true,
|
||||||
}) {
|
}: any) {
|
||||||
const { shapes, described } = entry.capabilities;
|
const { shapes, described } = entry.capabilities;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -252,11 +251,10 @@ export function CapabilityBrief({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
|
||||||
export function SkillDetails({
|
export function SkillDetails({
|
||||||
entry, open, onOpenChange, isEnabledOn, onToggle, busy = false,
|
entry, open, onOpenChange, isEnabledOn, onToggle, busy = false,
|
||||||
fields = null, onRunTest = null, test = null,
|
fields = null, onRunTest = null, test = null,
|
||||||
}) {
|
}: any) {
|
||||||
/**
|
/**
|
||||||
* The surface this would run on.
|
* The surface this would run on.
|
||||||
*
|
*
|
||||||
@@ -45,8 +45,7 @@ const OPTIONS = [
|
|||||||
export const supportedSurfaces = (type) =>
|
export const supportedSurfaces = (type) =>
|
||||||
OPTIONS.filter((o) => o.supported(type)).map((o) => o.id);
|
OPTIONS.filter((o) => o.supported(type)).map((o) => o.id);
|
||||||
|
|
||||||
/** @param {any} props */
|
export function SurfaceSelect({ type, value, onChange, name }: any) {
|
||||||
export function SurfaceSelect({ type, value, onChange, name }) {
|
|
||||||
return (
|
return (
|
||||||
<div role="radiogroup" aria-label={`Run ${name} as`} className="space-y-1.5">
|
<div role="radiogroup" aria-label={`Run ${name} as`} className="space-y-1.5">
|
||||||
{OPTIONS.map((option) => {
|
{OPTIONS.map((option) => {
|
||||||
@@ -57,8 +57,7 @@ function ConstrainedTag() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
export function AgentBadge({ page }: any) {
|
||||||
export function AgentBadge({ page }) {
|
|
||||||
const { agent, covers } = useActiveAgent();
|
const { agent, covers } = useActiveAgent();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { usePreferences } from '@/lib/krowHooks';
|
import { usePreferences } from '@/lib/krowHooks';
|
||||||
import { allAgents } from '@/lib/agents/registry';
|
import { AGENTS, readAgentRegistry } from '@/lib/agents/registry';
|
||||||
|
import { sourcesFrom, useAgentDefinitions } from '@/lib/agents/agentStore';
|
||||||
import {
|
import {
|
||||||
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
|
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
|
||||||
} from '@/lib/agents/runtime';
|
} from '@/lib/agents/runtime';
|
||||||
@@ -73,12 +74,30 @@ function writeSelection(selection) {
|
|||||||
export function AgentProvider({ contextId = null, children }) {
|
export function AgentProvider({ contextId = null, children }) {
|
||||||
const preferences = usePreferences();
|
const preferences = usePreferences();
|
||||||
|
|
||||||
/* Shipped definitions plus anything this account has authored, read through
|
/**
|
||||||
the one registry so the switcher and the management page cannot disagree
|
* Shipped definitions plus anything this account has authored.
|
||||||
about what exists. */
|
*
|
||||||
|
* Read from `agent-definitions` — the store the Agent Registry and Agent
|
||||||
|
* Configure write to — rather than from `preferences.customAgents`, which is
|
||||||
|
* where authored agents used to live. That move happened for the management
|
||||||
|
* screens and this was left behind, so the panel's idea of an agent was the
|
||||||
|
* shipped file and nothing else: an agent edited in Configure looked saved,
|
||||||
|
* and the agent answering beside it was still the version off disk.
|
||||||
|
*
|
||||||
|
* It is only visible once something actually depends on an authored field.
|
||||||
|
* Attaching a skill is that: ownership is read off `agent.skills`, and an
|
||||||
|
* attachment made in Configure has to be the one the page sees, or the two
|
||||||
|
* halves of the product disagree about what this agent owns.
|
||||||
|
*/
|
||||||
|
const definitions = useAgentDefinitions();
|
||||||
|
const shippedIds = React.useMemo(() => new Set(AGENTS.map((a) => a.id)), []);
|
||||||
|
const stored = React.useMemo(
|
||||||
|
() => sourcesFrom(definitions.data || [], shippedIds),
|
||||||
|
[definitions.data, shippedIds]
|
||||||
|
);
|
||||||
const agents = React.useMemo(
|
const agents = React.useMemo(
|
||||||
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
|
() => readAgentRegistry(stored, { customSkills: preferences.customSkills || [] }).agents,
|
||||||
[preferences.customAgents, preferences.customSkills]
|
[stored, preferences.customSkills]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [selection, setSelection] = React.useState(() => readSelection());
|
const [selection, setSelection] = React.useState(() => readSelection());
|
||||||
@@ -86,9 +86,8 @@ function FeedbackControls({ feedback, onFeedback }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Message = React.memo(
|
export const Message = React.memo(
|
||||||
/** @param {any} props */
|
|
||||||
({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null,
|
({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null,
|
||||||
feedback = null, onFeedback = null }) => {
|
feedback = null, onFeedback = null }: any) => {
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
@@ -268,7 +268,7 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
|||||||
|
|
||||||
/* Width is written on every drag frame, so persistence is debounced rather
|
/* Width is written on every drag frame, so persistence is debounced rather
|
||||||
than hitting sessionStorage sixty times a second. */
|
than hitting sessionStorage sixty times a second. */
|
||||||
const setWidthState = React.useCallback((next) => {
|
const setWidthState: any = React.useCallback((next) => {
|
||||||
const clamped = clampWidth(next);
|
const clamped = clampWidth(next);
|
||||||
setWidth(clamped);
|
setWidth(clamped);
|
||||||
clearTimeout(setWidthState.timer);
|
clearTimeout(setWidthState.timer);
|
||||||
@@ -15,8 +15,7 @@ import { useAssistantPanel } from './AssistantPanelContext';
|
|||||||
* It self-hides on pages with no assistant, so a page can render it
|
* It self-hides on pages with no assistant, so a page can render it
|
||||||
* unconditionally and never needs to know the placement rules.
|
* unconditionally and never needs to know the placement rules.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
export function AssistantTrigger({ className = '', size = 'default' }: any) {
|
||||||
export function AssistantTrigger({ className = '', size = 'default' }) {
|
|
||||||
const { supported, isOpen, toggle, context } = useAssistantPanel();
|
const { supported, isOpen, toggle, context } = useAssistantPanel();
|
||||||
|
|
||||||
if (!supported) return null;
|
if (!supported) return null;
|
||||||
@@ -2,21 +2,24 @@ import * as React from 'react';
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
|
History, Home, Maximize2, Minimize2, PanelRightClose, Trash2, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Surface } from '@/components/ds/Surface';
|
import { Surface } from '@/components/ds/Surface';
|
||||||
import { IconButton } from '@/components/ds/IconButton';
|
import { IconButton } from '@/components/ds/IconButton';
|
||||||
import { Alert } from '@/components/ds/Alert';
|
import { Alert } from '@/components/ds/Alert';
|
||||||
import {
|
import {
|
||||||
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
|
useAssignments, useAssignWorkers, useCreateWorkerWithRole, useCreateJobPosting,
|
||||||
|
useGenerateJobDescription,
|
||||||
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
|
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
|
||||||
useUpdateJobPosting,
|
useUpdateJobPosting,
|
||||||
usePreferences, useRoleCategories,
|
usePreferences, useRoleCategories,
|
||||||
} from '@/lib/krowHooks';
|
} from '@/lib/krowHooks';
|
||||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||||
import { runAction } from '@/lib/skills/actions';
|
import { runAction } from '@/lib/skills/actions';
|
||||||
import { allSkills, pageKeyForContext } from '@/lib/skills/registry';
|
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||||
|
import { allSkills, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
|
||||||
|
import { actionSuggestions } from '@/lib/skills/tools';
|
||||||
import { suggestionChips } from '@/lib/skills/serverSuggestions';
|
import { suggestionChips } from '@/lib/skills/serverSuggestions';
|
||||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||||
import { profileForEmail } from '@/lib/skillGraph';
|
import { profileForEmail } from '@/lib/skillGraph';
|
||||||
@@ -28,6 +31,7 @@ import { buildIntro } from './dynamic';
|
|||||||
import { agentScopedDisabled } from '@/lib/agents/runtime';
|
import { agentScopedDisabled } from '@/lib/agents/runtime';
|
||||||
import { buildOwliverContext } from '@/lib/agents/context';
|
import { buildOwliverContext } from '@/lib/agents/context';
|
||||||
import { AgentBadge } from './AgentBadge';
|
import { AgentBadge } from './AgentBadge';
|
||||||
|
import { useLanguage } from './language';
|
||||||
import { useActiveAgent } from './AgentContext';
|
import { useActiveAgent } from './AgentContext';
|
||||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||||
import { PromptInput } from './PromptInput';
|
import { PromptInput } from './PromptInput';
|
||||||
@@ -38,7 +42,7 @@ import { PromptChips } from './PromptChips';
|
|||||||
*
|
*
|
||||||
* Identity, not emptiness: `prompts` is recomputed on every keystroke, and a
|
* Identity, not emptiness: `prompts` is recomputed on every keystroke, and a
|
||||||
* fresh `[]` each time would rerender the row — and anything memoized against
|
* fresh `[]` each time would rerender the row — and anything memoized against
|
||||||
* it — throughout the whole of a query that is still too short to match.
|
* it — on every turn that offers nothing.
|
||||||
*/
|
*/
|
||||||
const EMPTY_PROMPTS = [];
|
const EMPTY_PROMPTS = [];
|
||||||
|
|
||||||
@@ -52,41 +56,6 @@ const EMPTY_PROMPTS = [];
|
|||||||
*/
|
*/
|
||||||
const EMPTY_SUGGESTIONS = [];
|
const EMPTY_SUGGESTIONS = [];
|
||||||
|
|
||||||
/**
|
|
||||||
* How long a pause counts as having finished typing.
|
|
||||||
*
|
|
||||||
* Short enough that the chips feel like they are keeping up, long enough that a
|
|
||||||
* word typed at speed is one request rather than eight. The endpoint is cached
|
|
||||||
* per query, so a reader deleting back to something already asked pays nothing
|
|
||||||
* either way.
|
|
||||||
*/
|
|
||||||
const SUGGEST_DEBOUNCE_MS = 180;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A value, held still until it stops changing.
|
|
||||||
*
|
|
||||||
* Deliberately generic and local: it debounces the composer's contents and
|
|
||||||
* nothing else, and the alternative — debouncing inside the query hook — would
|
|
||||||
* make every other caller of that hook pay for a delay it did not ask for.
|
|
||||||
*/
|
|
||||||
function useDebounced(value, delay) {
|
|
||||||
const [settled, setSettled] = React.useState(value);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
/* An emptied composer settles immediately. Waiting would leave the previous
|
|
||||||
query's chips under a blank input for a fifth of a second, which reads as
|
|
||||||
the panel not having noticed. */
|
|
||||||
if (!value) {
|
|
||||||
setSettled(value);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => setSettled(value), delay);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [value, delay]);
|
|
||||||
|
|
||||||
return settled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owliver History — the conversations that came before.
|
* Owliver History — the conversations that came before.
|
||||||
*
|
*
|
||||||
@@ -168,77 +137,98 @@ function HistoryView({ groups, currentId, onOpen, onForget }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the Back to Home row on scroll direction, not scroll position.
|
* Whether the composer is offering anything, as one rule in one place.
|
||||||
*
|
*
|
||||||
* Hidden while reading downwards, back the instant the user scrolls up — the
|
* Extracted because it is the whole of the interaction and every clause is a
|
||||||
* way out of a long answer should not be something you have to scroll all the
|
* decision somebody could reasonably make differently:
|
||||||
* way to the top to reach. Direction is read off the panel's own scrolling
|
|
||||||
* element (the body region below), never the window: the page behind the panel
|
|
||||||
* does not move when the thread does.
|
|
||||||
*
|
*
|
||||||
* The listener is passive and rAF-throttled, and `setVisible` is only ever
|
* focused — an offer belongs to the thing you are about to type into. An
|
||||||
* called with a value that can change — React bails out on an identical one, so
|
* unfocused composer showing suggestions is the panel talking
|
||||||
* a fast scroll costs at most one render per direction change.
|
* first.
|
||||||
*
|
* !busy — nothing is offered while an answer is still arriving; the next
|
||||||
* `pinToBottom` is the same follow-the-stream scroll the panel already did,
|
* question is not knowable until this one lands.
|
||||||
* routed through here so a programmatic jump is not mistaken for the user
|
* chat — History is a different view with a different body.
|
||||||
* scrolling down and does not hide the control under them.
|
* count — nothing to say, nothing shown, and this clause now carries what
|
||||||
|
* a `!typed` test used to. Typing does not hide the panel by
|
||||||
|
* rule; it changes what the panel HAS. An empty composer offers
|
||||||
|
* follow-ups, a typed action intent offers the matching actions,
|
||||||
|
* and arbitrary partial text matches no action and so offers
|
||||||
|
* nothing — which is the same outcome by a more honest route,
|
||||||
|
* and the reason "Create" can be answered while "he" cannot.
|
||||||
*/
|
*/
|
||||||
function useDirectionalNav(scrollRef, { active, resetKey }) {
|
export const shouldShowSuggestions = ({ focused, busy, view, count }) => Boolean(
|
||||||
const [visible, setVisible] = React.useState(true);
|
focused && !busy && view === 'chat' && count > 0
|
||||||
const lastY = React.useRef(0);
|
);
|
||||||
|
|
||||||
/* Ignore sub-pixel and trackpad jitter, but nothing a deliberate scroll would
|
/**
|
||||||
produce: a real direction change clears this within one frame. */
|
* The questions on offer, above the composer they belong to.
|
||||||
const NOISE = 4;
|
*
|
||||||
|
* A labelled panel rather than a bare row of chips: the label is what makes
|
||||||
|
* three sentences read as an offer rather than as something the assistant just
|
||||||
|
* said. It sits inside the composer's own region, above the input and below the
|
||||||
|
* conversation, so it reads as part of the thing you are about to type into.
|
||||||
|
*
|
||||||
|
* Always mounted, height animated. Mounting on open would move the input the
|
||||||
|
* instant the panel appeared and again when it left, so the caret would jump
|
||||||
|
* under the reader's hands every time they focused the box. Animating a
|
||||||
|
* collapsed height keeps the geometry continuous, and `pointer-events-none`
|
||||||
|
* plus `inert`-style tab removal means the closed panel cannot be clicked or
|
||||||
|
* tabbed into.
|
||||||
|
*
|
||||||
|
* `max-h` is generous enough for four wrapped questions and scrolls past that,
|
||||||
|
* so a narrow panel on a small screen cannot push the composer off the bottom.
|
||||||
|
*/
|
||||||
|
function SuggestedQuestions({ prompts, open, onSelect }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden transition-all duration-200 ease-out motion-reduce:transition-none',
|
||||||
|
open
|
||||||
|
? 'max-h-56 translate-y-0 opacity-100'
|
||||||
|
: 'pointer-events-none max-h-0 translate-y-1 opacity-0'
|
||||||
|
)}
|
||||||
|
aria-hidden={open ? undefined : 'true'}
|
||||||
|
>
|
||||||
|
<p className="px-1 pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||||
|
Suggested questions
|
||||||
|
</p>
|
||||||
|
{/* The existing chip component, and the existing submit path behind it —
|
||||||
|
`runPrompt` is the same handler a typed question goes through. */}
|
||||||
|
<PromptChips
|
||||||
|
prompts={prompts}
|
||||||
|
onSelect={onSelect}
|
||||||
|
align="start"
|
||||||
|
focusable={open}
|
||||||
|
className="pb-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const pinToBottom = React.useCallback(() => {
|
/**
|
||||||
|
* Keep the thread pinned to its newest content.
|
||||||
|
*
|
||||||
|
* All that survives of a floating "Back to Home" row that used to sit between
|
||||||
|
* the header and the conversation. That row was `absolute`, so it did not take
|
||||||
|
* part in the layout — it OVERLAID the top of the scrolling region, and the
|
||||||
|
* first line or two of a long answer arrived underneath it. It hid on
|
||||||
|
* down-scroll to compensate, which meant the fix for a control covering the
|
||||||
|
* answer was to make the control disappear while you read.
|
||||||
|
*
|
||||||
|
* The navigation it carried now lives in the header, where the panel's other
|
||||||
|
* controls already are and where nothing can cover the response. What is left
|
||||||
|
* here is the scroll behaviour, which was always a separate concern that had
|
||||||
|
* been folded in because the two happened to share a listener.
|
||||||
|
*
|
||||||
|
* Direct `scrollTop` rather than smooth scrolling: at streaming frequency a
|
||||||
|
* smooth scroll never catches up and the thread visibly lags the text.
|
||||||
|
*/
|
||||||
|
function usePinToBottom(scrollRef) {
|
||||||
|
return React.useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.scrollTop = el.scrollHeight;
|
el.scrollTop = el.scrollHeight;
|
||||||
/* Adopt the new position before the scroll event lands, so the next read
|
|
||||||
sees no delta and the row keeps whatever state the user left it in. */
|
|
||||||
lastY.current = el.scrollTop;
|
|
||||||
}, [scrollRef]);
|
}, [scrollRef]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
/* Nothing to hide when the row is not rendered — and a fresh thread or a
|
|
||||||
newly opened panel always starts with it showing. */
|
|
||||||
setVisible(true);
|
|
||||||
if (!el || !active) return undefined;
|
|
||||||
|
|
||||||
lastY.current = el.scrollTop;
|
|
||||||
let frame = 0;
|
|
||||||
|
|
||||||
const read = () => {
|
|
||||||
frame = 0;
|
|
||||||
const y = el.scrollTop;
|
|
||||||
if (y <= 0) {
|
|
||||||
lastY.current = y;
|
|
||||||
setVisible(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const delta = y - lastY.current;
|
|
||||||
/* Leave `lastY` alone below the threshold so slow scrolls accumulate
|
|
||||||
rather than being swallowed frame by frame. */
|
|
||||||
if (Math.abs(delta) < NOISE) return;
|
|
||||||
lastY.current = y;
|
|
||||||
setVisible(delta < 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onScroll = () => {
|
|
||||||
if (!frame) frame = requestAnimationFrame(read);
|
|
||||||
};
|
|
||||||
|
|
||||||
el.addEventListener('scroll', onScroll, { passive: true });
|
|
||||||
return () => {
|
|
||||||
el.removeEventListener('scroll', onScroll);
|
|
||||||
if (frame) cancelAnimationFrame(frame);
|
|
||||||
};
|
|
||||||
}, [scrollRef, active, resetKey]);
|
|
||||||
|
|
||||||
return { visible, pinToBottom };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -414,6 +404,13 @@ export default function KrowAssistant({
|
|||||||
*/
|
*/
|
||||||
const [view, setView] = React.useState('chat');
|
const [view, setView] = React.useState('chat');
|
||||||
|
|
||||||
|
/* The account's language, chosen in the account menu rather than here. The
|
||||||
|
panel reads it and never sets it: it is an application setting that happens
|
||||||
|
to govern the one piece of text this product generates, not a property of
|
||||||
|
the conversation. It applies from the next answer onward — answers already
|
||||||
|
on screen stay as they were written. */
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A skill asked for something to happen. The names come from the skill file;
|
* A skill asked for something to happen. The names come from the skill file;
|
||||||
* `runAction` decides what they mean and refuses anything the file did not
|
* `runAction` decides what they mean and refuses anything the file did not
|
||||||
@@ -441,6 +438,66 @@ export default function KrowAssistant({
|
|||||||
return createJob.mutateAsync(result.data);
|
return createJob.mutateAsync(result.data);
|
||||||
}, [createJob]);
|
}, [createJob]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every conversation's write, keyed by the flow's id.
|
||||||
|
*
|
||||||
|
* `useAssistant` looks the writer up by the flow the answering skill declares,
|
||||||
|
* so adding a conversation is adding an entry here rather than another prop
|
||||||
|
* threaded through the panel.
|
||||||
|
*/
|
||||||
|
const createRole = useCreateWorkerWithRole();
|
||||||
|
const createEmployeeRole = React.useCallback(async (draft, skill, status) => {
|
||||||
|
const result = runAction('create_employee_role', { draft, skill, status });
|
||||||
|
if (result?.type !== 'create_employee_role') return null;
|
||||||
|
return createRole.mutateAsync(result.data);
|
||||||
|
}, [createRole]);
|
||||||
|
|
||||||
|
/* The page's layout session, mounted by the layout above both this panel and
|
||||||
|
the page. Null on a surface that composes no tree, which is every page that
|
||||||
|
has not migrated — and every branch that reads it checks first. */
|
||||||
|
const uiEditing = useUiEditing();
|
||||||
|
|
||||||
|
const flowWriters = React.useMemo(() => ({
|
||||||
|
position: createPosition,
|
||||||
|
'employee-role': createEmployeeRole,
|
||||||
|
}), [createPosition, createEmployeeRole]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The clients this organization already staffs for.
|
||||||
|
*
|
||||||
|
* Distinct company names off the postings the panel has already loaded for
|
||||||
|
* this caller — org-scoped by the API, and nothing here widens that. They are
|
||||||
|
* offered as chips on the conversation's company question so an existing
|
||||||
|
* client is a tap, while typing a name that is not on the list is still how a
|
||||||
|
* new one is named. There is no company record to create: see the `@companies`
|
||||||
|
* note in `lib/skills/flows/position.js`.
|
||||||
|
*
|
||||||
|
* Deliberately NOT sorted here. The order is the one the postings arrived in
|
||||||
|
* — the API's `-created_date` — so the clients staffed for most recently are
|
||||||
|
* the ones offered first, and the panel does no ranking of its own. That last
|
||||||
|
* part is a rule `npm test` enforces structurally, and it is the right rule:
|
||||||
|
* a second opinion formed in the panel outranking the server's is exactly the
|
||||||
|
* failure that decays quietly.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The workers a role can be recorded against.
|
||||||
|
*
|
||||||
|
* The profiles the panel already holds for this caller — org-scoped by the
|
||||||
|
* API. The conversation offers the names as chips and resolves a pick back to
|
||||||
|
* the profile id and email, so the row names a real person rather than
|
||||||
|
* whatever was typed. The operator is never the subject: the question is
|
||||||
|
* required and there is no fallback to the session.
|
||||||
|
*/
|
||||||
|
const workers = React.useMemo(() => (facts.profiles || []).map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
name: w.full_name || w.name || '',
|
||||||
|
email: w.email || '',
|
||||||
|
})).filter((w) => w.email), [facts.profiles]);
|
||||||
|
|
||||||
|
const companies = React.useMemo(() => [...new Set(
|
||||||
|
(facts.postings || []).map((p) => String(p.company || '').trim()).filter(Boolean)
|
||||||
|
)], [facts.postings]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What to ask next, from the server, after something has been written.
|
* What to ask next, from the server, after something has been written.
|
||||||
*
|
*
|
||||||
@@ -460,13 +517,16 @@ export default function KrowAssistant({
|
|||||||
* used here — it was computed before the row existed.
|
* used here — it was computed before the row existed.
|
||||||
*/
|
*/
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const refreshSuggestions = React.useCallback(async () => {
|
const refreshSuggestions = React.useCallback(async ({ query = '' } = {}) => {
|
||||||
if (!owliverPage) return [];
|
if (!owliverPage) return [];
|
||||||
const fresh = await queryClient.fetchQuery({
|
const fresh = await queryClient.fetchQuery({
|
||||||
/* The empty query string is the untyped request — the same key
|
/* An empty query is the untyped request — the same key
|
||||||
`useOwliverSuggestions` uses when the composer is empty. */
|
`useOwliverSuggestions` uses when the composer is empty, and what a
|
||||||
queryKey: ['owliverSuggestions', owliverPage, ''],
|
write wants: the page changed, so ask what matters on it now.
|
||||||
queryFn: () => fetchOwliverSuggestions({ page: owliverPage }),
|
A question passed in ranks the same catalogue AGAINST that question,
|
||||||
|
which is what makes a follow-up follow from something. */
|
||||||
|
queryKey: ['owliverSuggestions', owliverPage, query],
|
||||||
|
queryFn: () => fetchOwliverSuggestions({ page: owliverPage, query }),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
return suggestionChips(fresh || [], context.id);
|
return suggestionChips(fresh || [], context.id);
|
||||||
@@ -562,7 +622,13 @@ export default function KrowAssistant({
|
|||||||
pageLabel: context.page,
|
pageLabel: context.page,
|
||||||
onNavigate: goToPage,
|
onNavigate: goToPage,
|
||||||
onAction: performAction,
|
onAction: performAction,
|
||||||
onCreatePosition: createPosition,
|
flowWriters,
|
||||||
|
uiEditing,
|
||||||
|
companies,
|
||||||
|
/* The postings this caller can already see — the evidence behind
|
||||||
|
role-aware certification suggestions. */
|
||||||
|
postings: facts.postings || [],
|
||||||
|
workers,
|
||||||
onRefreshSuggestions: refreshSuggestions,
|
onRefreshSuggestions: refreshSuggestions,
|
||||||
onUpdatePosition,
|
onUpdatePosition,
|
||||||
onGenerateDescription,
|
onGenerateDescription,
|
||||||
@@ -579,6 +645,7 @@ export default function KrowAssistant({
|
|||||||
agentCoversPage,
|
agentCoversPage,
|
||||||
agentSuggestion: defaultAgent,
|
agentSuggestion: defaultAgent,
|
||||||
owliverContext,
|
owliverContext,
|
||||||
|
language,
|
||||||
});
|
});
|
||||||
|
|
||||||
/* A block inside an answer asking the next question, in place. Same entry
|
/* A block inside an answer asking the next question, in place. Same entry
|
||||||
@@ -612,12 +679,12 @@ export default function KrowAssistant({
|
|||||||
const scrollRef = React.useRef(null);
|
const scrollRef = React.useRef(null);
|
||||||
const isEmpty = messages.length === 0 && !pending;
|
const isEmpty = messages.length === 0 && !pending;
|
||||||
|
|
||||||
/* The Back to Home row only exists in the states that are not already home. */
|
const pinToBottom = usePinToBottom(scrollRef);
|
||||||
const showBackRow = view === 'history' || messages.length > 0 || Boolean(pending);
|
|
||||||
const { visible: backVisible, pinToBottom } = useDirectionalNav(scrollRef, {
|
/* Whether there is anything to leave. The header's own control is shown on
|
||||||
active: showBackRow,
|
exactly the states that are not already home — the same test the removed
|
||||||
resetKey: `${view}:${conversationId || ''}`,
|
row used, now deciding a button rather than an overlay. */
|
||||||
});
|
const canGoHome = view === 'history' || messages.length > 0 || Boolean(pending);
|
||||||
|
|
||||||
/* Greeting and suggestions come from live data, so they recompute only when
|
/* Greeting and suggestions come from live data, so they recompute only when
|
||||||
the data or the page actually changes. */
|
the data or the page actually changes. */
|
||||||
@@ -627,60 +694,98 @@ export default function KrowAssistant({
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the chip row actually shows, which is one of three separate things.
|
* What the composer offers, and when.
|
||||||
*
|
*
|
||||||
* They are separate states, not one merged list, because they answer to
|
* Three rules, and they are about DIFFERENT questions — what to show, and
|
||||||
* different owners. Follow-ups belong to the answer that raised them; the
|
* whether to show anything at all.
|
||||||
* suggestions belong to the server. Only one of them can be true at a time,
|
|
||||||
* and the order below is that precedence.
|
|
||||||
*
|
*
|
||||||
* 1. Follow-ups. When an answer ends by asking something, its chips *are*
|
* WHAT. Before the first question, the page's own suggested questions: the
|
||||||
* the answers to it — the role list after "create a position". They are
|
* reader has asked nothing, so there is nothing to follow up and the useful
|
||||||
* never capped and never filtered, and they stand until the next turn or
|
* offer is the range of what this page can answer. After an answer, the
|
||||||
* until the reader starts typing something else.
|
* follow-ups that answer carried — questions this conversation has not
|
||||||
|
* already covered, worked out in `nextSteps`. Never both: a thread that has
|
||||||
|
* run out of new ground shows nothing rather than falling back to the
|
||||||
|
* catalogue it has already been through.
|
||||||
*
|
*
|
||||||
* 2. The server's suggestions. From the moment there is something in the
|
* WHEN. Only while the composer has focus and is empty. Suggestions used to
|
||||||
* composer, `GET /api/v1/owliver/suggestions` is asked what this page
|
* appear from the second character typed, which is the wrong moment twice
|
||||||
* can usefully answer for this query, and its reply is rendered in the
|
* over: a reader who is typing has already decided what to ask, and two
|
||||||
* order it arrived. The panel does not rank, score, filter or reorder
|
* characters is not enough to know what they mean. So typing hides them and
|
||||||
* it: which readings exist depends on the caller's role and on what is
|
* the reader's own text is never touched.
|
||||||
* actually in the database, and neither of those is knowable here.
|
|
||||||
*
|
*
|
||||||
* 3. Nothing. An empty composer offers no chips at all. The panel used to
|
* The typed-query branch is gone with it. The endpoint still takes a query
|
||||||
* open on a dozen of them, which taught the range of what could be asked
|
* and `nextSteps` still uses it — that is what makes a follow-up follow from
|
||||||
* by saying all of it at once and pushed the composer — the thing the
|
* something — but nothing asks it on a keystroke any more.
|
||||||
* reader came for — under a wall of suggestions. The greeting still
|
|
||||||
* carries the page's context; `buildIntro` reads the same fact sheet it
|
|
||||||
* always did.
|
|
||||||
*/
|
*/
|
||||||
const followUp = messages[messages.length - 1]?.followUp;
|
const followUp = messages[messages.length - 1]?.followUp;
|
||||||
const typed = input.trim();
|
const typed = input.trim();
|
||||||
|
const [composerFocused, setComposerFocused] = React.useState(false);
|
||||||
|
|
||||||
/**
|
/* The page's own questions, for a thread that has not started. One untyped
|
||||||
* The request behind (2), debounced.
|
request, cached by the hook, asked only while it could be shown. */
|
||||||
*
|
const { data: pageSuggestions = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
|
||||||
* The endpoint is cheap and cached per query, but a keystroke is not a
|
|
||||||
* decision — a reader typing "positions" would otherwise fire nine requests
|
|
||||||
* to see the answer to the ninth. A short delay means one request per pause,
|
|
||||||
* and `placeholderData` in the hook keeps the previous answer on screen
|
|
||||||
* meanwhile so the row does not empty and refill.
|
|
||||||
*
|
|
||||||
* Only asked while there is something in the composer. An empty one offers no
|
|
||||||
* chips, so there would be nothing to render the answer into — and a reader
|
|
||||||
* who starts typing has left the follow-up behind, which is why typing
|
|
||||||
* supersedes it rather than being ranked against it.
|
|
||||||
*/
|
|
||||||
const debouncedQuery = useDebounced(typed, SUGGEST_DEBOUNCE_MS);
|
|
||||||
const { data: suggested = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
|
|
||||||
page: owliverPage,
|
page: owliverPage,
|
||||||
query: debouncedQuery,
|
enabled: Boolean(owliverPage) && messages.length === 0,
|
||||||
enabled: Boolean(debouncedQuery),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is on offer, which now depends on whether anything has been typed.
|
||||||
|
*
|
||||||
|
* TYPED — the actions this page can perform that the text is starting to
|
||||||
|
* name, and nothing else. "Create" reaches "Create a position" here because
|
||||||
|
* that is a skill on this page that declares an action; "he" reaches nothing,
|
||||||
|
* and neither does "abc". This is the narrow case the composer was missing:
|
||||||
|
* a reader typing an action intent had to finish the sentence unaided, while
|
||||||
|
* a reader typing anything at all used to get the whole page catalogue.
|
||||||
|
*
|
||||||
|
* EMPTY — the follow-ups the last answer left, or, before a thread starts,
|
||||||
|
* what this page can be asked. Unchanged.
|
||||||
|
*/
|
||||||
|
const reachableSkills = React.useMemo(
|
||||||
|
() => skillsForContext(context.id, disabledSkills, preferences.customSkills || []),
|
||||||
|
[context.id, disabledSkills, preferences.customSkills]
|
||||||
|
);
|
||||||
|
|
||||||
const prompts = React.useMemo(() => {
|
const prompts = React.useMemo(() => {
|
||||||
if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
if (typed) return actionSuggestions(typed, reachableSkills);
|
||||||
return suggestionChips(suggested, context.id);
|
if (messages.length) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
||||||
}, [typed, followUp, suggested, context.id]);
|
return suggestionChips(pageSuggestions, context.id);
|
||||||
|
}, [typed, reachableSkills, messages.length, followUp, pageSuggestions, context.id]);
|
||||||
|
|
||||||
|
const showSuggestions = shouldShowSuggestions({
|
||||||
|
focused: composerFocused, busy, view, count: prompts.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus, read at the composer rather than at the input.
|
||||||
|
*
|
||||||
|
* A chip lives inside the same region, so moving to one keeps the region
|
||||||
|
* focused and the panel open long enough for the click to land — which a
|
||||||
|
* `blur` handler on the textarea alone would not do. `relatedTarget` is what
|
||||||
|
* makes "clicked outside" mean it: focus leaving for anywhere else in the
|
||||||
|
* document closes the panel.
|
||||||
|
*/
|
||||||
|
const onComposerBlur = React.useCallback((event) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) setComposerFocused(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asking closes the panel, and only focus reopens it.
|
||||||
|
*
|
||||||
|
* A blur handler alone was not enough, which a live run showed: after a
|
||||||
|
* question was sent, focus ended up on `document.body` while `composerFocused`
|
||||||
|
* was still true, so the panel came back on its own under the finished answer
|
||||||
|
* with nobody's cursor in the box. The subtree re-renders while the answer
|
||||||
|
* streams, and a focus lost that way does not always arrive as a blur this
|
||||||
|
* handler sees.
|
||||||
|
*
|
||||||
|
* So submitting is treated as what it is — the reader has finished with the
|
||||||
|
* composer for now — rather than relying on a blur that may never come. The
|
||||||
|
* state table is unchanged: focus opens it, everything else leaves it shut.
|
||||||
|
*/
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (busy) setComposerFocused(false);
|
||||||
|
}, [busy]);
|
||||||
|
|
||||||
/* The newest assistant turn, which is the one that carries the rating. */
|
/* The newest assistant turn, which is the one that carries the rating. */
|
||||||
const lastAnswerIndex = React.useMemo(
|
const lastAnswerIndex = React.useMemo(
|
||||||
@@ -833,6 +938,10 @@ export default function KrowAssistant({
|
|||||||
different places with different affordances. The panel does the
|
different places with different affordances. The panel does the
|
||||||
first job only; the registry behind it is unchanged. */}
|
first job only; the registry behind it is unchanged. */}
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
|
{/* The way back to a clean panel, positioned in front of History */}
|
||||||
|
{canGoHome && (
|
||||||
|
<IconButton icon={Home} label="Back to home" variant="ghost" size="sm" onClick={goHome} />
|
||||||
|
)}
|
||||||
{/* History lives with the other window controls rather than in the
|
{/* History lives with the other window controls rather than in the
|
||||||
body, so the layout of the panel is unchanged whether or not
|
body, so the layout of the panel is unchanged whether or not
|
||||||
there is anything to show. It toggles: pressing it again returns
|
there is anything to show. It toggles: pressing it again returns
|
||||||
@@ -845,9 +954,6 @@ export default function KrowAssistant({
|
|||||||
aria-pressed={view === 'history'}
|
aria-pressed={view === 'history'}
|
||||||
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
|
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
|
||||||
/>
|
/>
|
||||||
{messages.length > 0 && view === 'chat' && (
|
|
||||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
|
||||||
)}
|
|
||||||
{expanded
|
{expanded
|
||||||
? onRestore && (
|
? onRestore && (
|
||||||
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
|
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
|
||||||
@@ -861,37 +967,6 @@ export default function KrowAssistant({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating directional Back to Home row — reveals on UP-scroll, hides on DOWN-scroll */}
|
|
||||||
{showBackRow && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
`absolute top-[3.25rem] left-0 right-0 z-20 flex items-center justify-between gap-2
|
|
||||||
border-b border-border bg-white/95 dark:bg-slate-900/95 px-4 py-2 shadow-sm backdrop-blur-md
|
|
||||||
transition-all duration-200 ease-out motion-reduce:transition-none`,
|
|
||||||
backVisible
|
|
||||||
? 'translate-y-0 opacity-100 pointer-events-auto'
|
|
||||||
: '-translate-y-full opacity-0 pointer-events-none'
|
|
||||||
)}
|
|
||||||
aria-hidden={backVisible ? undefined : 'true'}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goHome}
|
|
||||||
tabIndex={backVisible ? undefined : -1}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded text-caption font-semibold text-ink-1 dark:text-white transition-colors
|
|
||||||
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 cursor-pointer"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-3.5 w-3.5 text-krow-blue" aria-hidden="true" />
|
|
||||||
<span>Back to Home</span>
|
|
||||||
</button>
|
|
||||||
<span className="truncate text-[11px] font-medium text-ink-3">
|
|
||||||
{view === 'history'
|
|
||||||
? `${history.length} conversation${history.length === 1 ? '' : 's'}`
|
|
||||||
: context.page}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/**
|
{/**
|
||||||
* A capability test running through this panel.
|
* A capability test running through this panel.
|
||||||
*
|
*
|
||||||
@@ -1001,27 +1076,19 @@ export default function KrowAssistant({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
|
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
|
||||||
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
|
{/* Focus is tracked on the whole region rather than on the textarea, so
|
||||||
{/* All suggestions on the landing screen, where they teach what can be
|
reaching for a suggestion does not close the panel out from under the
|
||||||
asked. Capped once a thread exists, because from then on the vertical
|
click. In normal flow, never floating: an overlay here would sit on
|
||||||
space belongs to the conversation. Expanded fits more per line, so it
|
top of the answer, which is the mistake the removed Back to Home row
|
||||||
can afford one more.
|
made. The body above is `flex-1`, so it yields the height and the
|
||||||
|
response stays whole and scrollable. */}
|
||||||
Follow-ups are never capped: when Owliver has asked a question, its
|
<div
|
||||||
chips *are* the answers, and hiding three of the six roles would make
|
className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5"
|
||||||
the flow look broken. */}
|
onFocusCapture={() => setComposerFocused(true)}
|
||||||
{!busy && view === 'chat' && (
|
onBlurCapture={onComposerBlur}
|
||||||
<PromptChips
|
>
|
||||||
prompts={prompts}
|
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
|
||||||
onSelect={runPrompt}
|
|
||||||
max={isEmpty || followUp?.length ? undefined : expanded ? 4 : 3}
|
|
||||||
align="start"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{composer}
|
{composer}
|
||||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
|
||||||
Owliver reads this page's data. Check anything you act on.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Surface>
|
</Surface>
|
||||||
);
|
);
|
||||||
@@ -23,8 +23,13 @@ import { cn } from '@/lib/utils';
|
|||||||
*
|
*
|
||||||
* Arrow keys move between chips, so the whole set is one tab stop.
|
* Arrow keys move between chips, so the whole set is one tab stop.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
export function PromptChips({
|
||||||
export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center', className = '' }) {
|
prompts = [], onSelect, max = 0, align = 'center', className = '',
|
||||||
|
/* Taken out of the tab order while the row is collapsed but still mounted:
|
||||||
|
a chip inside a zero-height container is invisible, and a Tab that lands on
|
||||||
|
something invisible is a keyboard user losing their place. */
|
||||||
|
focusable = true,
|
||||||
|
}: any) {
|
||||||
const chipRefs = React.useRef([]);
|
const chipRefs = React.useRef([]);
|
||||||
const visible = max ? prompts.slice(0, max) : prompts;
|
const visible = max ? prompts.slice(0, max) : prompts;
|
||||||
|
|
||||||
@@ -59,6 +64,7 @@ export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center',
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(prompt)}
|
onClick={() => onSelect(prompt)}
|
||||||
onKeyDown={(e) => onKeyDown(e, i)}
|
onKeyDown={(e) => onKeyDown(e, i)}
|
||||||
|
tabIndex={focusable ? undefined : -1}
|
||||||
title={prompt.prompt}
|
title={prompt.prompt}
|
||||||
className={cn(
|
className={cn(
|
||||||
/* 14px is the radius `rounded-full` already produces on a one-line
|
/* 14px is the radius `rounded-full` already produces on a one-line
|
||||||
@@ -17,7 +17,6 @@ import { cn } from '@/lib/utils';
|
|||||||
* gain — the suggestion chips already teach the range of what can be asked, and
|
* gain — the suggestion chips already teach the range of what can be asked, and
|
||||||
* they do it without a timer or a rerender every four seconds.
|
* they do it without a timer or a rerender every four seconds.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
|
||||||
export function PromptInput({
|
export function PromptInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -28,7 +27,7 @@ export function PromptInput({
|
|||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
align = 'left',
|
align = 'left',
|
||||||
className = '',
|
className = '',
|
||||||
}) {
|
}: any) {
|
||||||
const textareaRef = React.useRef(null);
|
const textareaRef = React.useRef(null);
|
||||||
const [focused, setFocused] = React.useState(false);
|
const [focused, setFocused] = React.useState(false);
|
||||||
|
|
||||||
@@ -22,10 +22,42 @@ import { usePageAction } from './PageContext';
|
|||||||
* snapshot, and settled blocks must not re-render with it.
|
* snapshot, and settled blocks must not re-render with it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_)/g;
|
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_|\[[^\]\n]+\]\([^)\s]*\))/g;
|
||||||
|
const LINK = /^\[([^\]\n]+)\]\(([^)\s]*)\)$/;
|
||||||
|
|
||||||
/** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is
|
/**
|
||||||
* carried by blocks, not by markup inside a paragraph. */
|
* Where a link in an answer is allowed to point.
|
||||||
|
*
|
||||||
|
* An allow-list, and it is a security boundary rather than a tidiness rule: the
|
||||||
|
* text being parsed here was written by a model, and a model reading a document
|
||||||
|
* that says "link to javascript:…" is exactly the injection §I7 of CLAUDE.md
|
||||||
|
* calls untrusted input. Anything not on this list renders as the plain text it
|
||||||
|
* came from — visible, inert, and obvious.
|
||||||
|
*
|
||||||
|
* `#` alone is deliberately absent. A bare empty anchor is the shape the
|
||||||
|
* citation sanitiser removes; one that reaches here is a link to nowhere, and
|
||||||
|
* showing its label as text is better than an anchor that does nothing.
|
||||||
|
*/
|
||||||
|
const isSafeHref = (href) => (
|
||||||
|
/^\/(?!\/)/.test(href) // in-app route
|
||||||
|
|| /^#[^\s]+$/.test(href) // an anchor on this page, but not a bare '#'
|
||||||
|
|| /^https?:\/\//i.test(href) // the open web
|
||||||
|
|| /^mailto:[^\s]+$/i.test(href)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline `**bold**`, `_italic_` and `[label](href)`.
|
||||||
|
*
|
||||||
|
* Links were the gap: `markdownToBlocks` never touched them, and this renderer
|
||||||
|
* had no case for them, so `[staffing policy](#staffing)` reached the reader as
|
||||||
|
* its own source. Structure is still carried by blocks rather than by markup —
|
||||||
|
* this stays three constructs, not a markdown library.
|
||||||
|
*
|
||||||
|
* A citation-shaped link never arrives here at all. `[337042b3](#)` is removed
|
||||||
|
* upstream in `provider.js`, by the wrapper's shape, before a block is built —
|
||||||
|
* so the two concerns stay apart: the sanitiser decides what is an internal
|
||||||
|
* reference, and this decides how a real link looks.
|
||||||
|
*/
|
||||||
function Inline({ value }) {
|
function Inline({ value }) {
|
||||||
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
|
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
|
||||||
|
|
||||||
@@ -36,6 +68,33 @@ function Inline({ value }) {
|
|||||||
if (part.startsWith('_') && part.endsWith('_')) {
|
if (part.startsWith('_') && part.endsWith('_')) {
|
||||||
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
|
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const link = LINK.exec(part);
|
||||||
|
if (link) {
|
||||||
|
const [, label, href] = link;
|
||||||
|
if (!isSafeHref(href)) return part;
|
||||||
|
/* An in-app route goes through the router, like every other internal link
|
||||||
|
in this file — a full page load would throw away the conversation the
|
||||||
|
reader is being pointed away from. Everything else is an anchor, and
|
||||||
|
anything leaving the app opens away from it. */
|
||||||
|
const external = /^https?:\/\//i.test(href);
|
||||||
|
const className = 'font-medium text-krow-blue underline decoration-krow-blue/30 underline-offset-2'
|
||||||
|
+ ' transition-colors hover:decoration-krow-blue focus-visible:outline-none'
|
||||||
|
+ ' focus-visible:ring-2 focus-visible:ring-krow-blue/50 rounded-sm';
|
||||||
|
|
||||||
|
if (href.startsWith('/')) return <Link key={i} to={href} className={className}>{label}</Link>;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={href}
|
||||||
|
className={className}
|
||||||
|
{...(external ? { target: '_blank', rel: 'noreferrer noopener' } : null)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return part;
|
return part;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -62,14 +121,14 @@ const TONE_BADGE = {
|
|||||||
|
|
||||||
/* ── Individual blocks ──────────────────────────────────────────────────── */
|
/* ── Individual blocks ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const TextBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const TextBlock = React.memo(({ block }: any) => (
|
||||||
<p className="text-body-sm leading-relaxed text-ink-2">
|
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||||
<Inline value={block.text} />
|
<Inline value={block.text} />
|
||||||
</p>
|
</p>
|
||||||
));
|
));
|
||||||
TextBlock.displayName = 'TextBlock';
|
TextBlock.displayName = 'TextBlock';
|
||||||
|
|
||||||
const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const HeadingBlock = React.memo(({ block }: any) => (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
|
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
|
||||||
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
|
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
|
||||||
@@ -78,7 +137,7 @@ const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
|||||||
HeadingBlock.displayName = 'HeadingBlock';
|
HeadingBlock.displayName = 'HeadingBlock';
|
||||||
|
|
||||||
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
||||||
const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const KpisBlock = React.memo(({ block }: any) => (
|
||||||
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
|
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
|
||||||
{block.items.map((item) => (
|
{block.items.map((item) => (
|
||||||
<div
|
<div
|
||||||
@@ -113,7 +172,7 @@ const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
|||||||
KpisBlock.displayName = 'KpisBlock';
|
KpisBlock.displayName = 'KpisBlock';
|
||||||
|
|
||||||
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
||||||
const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const StatusBlock = React.memo(({ block }: any) => (
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{block.items.map((item) => (
|
{block.items.map((item) => (
|
||||||
<li key={item.label} className="flex items-start gap-2">
|
<li key={item.label} className="flex items-start gap-2">
|
||||||
@@ -145,7 +204,7 @@ const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
|||||||
));
|
));
|
||||||
StatusBlock.displayName = 'StatusBlock';
|
StatusBlock.displayName = 'StatusBlock';
|
||||||
|
|
||||||
const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const MetersBlock = React.memo(({ block }: any) => (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{block.items.map((item) => (
|
{block.items.map((item) => (
|
||||||
<div key={item.label} className="space-y-1">
|
<div key={item.label} className="space-y-1">
|
||||||
@@ -167,7 +226,7 @@ MetersBlock.displayName = 'MetersBlock';
|
|||||||
* A table. Scrolls horizontally inside its own container so a wide comparison
|
* A table. Scrolls horizontally inside its own container so a wide comparison
|
||||||
* never widens the panel.
|
* never widens the panel.
|
||||||
*/
|
*/
|
||||||
const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
const TableBlock = React.memo(({ block }: any) => {
|
||||||
const cell = (value) => {
|
const cell = (value) => {
|
||||||
if (value == null || value === '') return <span className="text-ink-4">—</span>;
|
if (value == null || value === '') return <span className="text-ink-4">—</span>;
|
||||||
if (typeof value === 'object') {
|
if (typeof value === 'object') {
|
||||||
@@ -228,7 +287,7 @@ const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
|||||||
TableBlock.displayName = 'TableBlock';
|
TableBlock.displayName = 'TableBlock';
|
||||||
|
|
||||||
/** Stage progression, widths relative to the largest stage. */
|
/** Stage progression, widths relative to the largest stage. */
|
||||||
const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
const FunnelBlock = React.memo(({ block }: any) => {
|
||||||
const max = Math.max(...block.steps.map((s) => s.count), 1);
|
const max = Math.max(...block.steps.map((s) => s.count), 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -255,7 +314,7 @@ const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
|||||||
});
|
});
|
||||||
FunnelBlock.displayName = 'FunnelBlock';
|
FunnelBlock.displayName = 'FunnelBlock';
|
||||||
|
|
||||||
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
const ListBlock = React.memo(({ block }: any) => {
|
||||||
const Tag = block.ordered ? 'ol' : 'ul';
|
const Tag = block.ordered ? 'ol' : 'ul';
|
||||||
return (
|
return (
|
||||||
<Tag className="space-y-1.5">
|
<Tag className="space-y-1.5">
|
||||||
@@ -298,7 +357,7 @@ ListBlock.displayName = 'ListBlock';
|
|||||||
* clickable and doing nothing. An item may carry a hint *and* an action: the
|
* clickable and doing nothing. An item may carry a hint *and* an action: the
|
||||||
* hint explains the record, the action opens it.
|
* hint explains the record, the action opens it.
|
||||||
*/
|
*/
|
||||||
const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt }) => (
|
const InsightsBlock = React.memo(({ block, onPrompt }: any) => (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{block.items.map((item, i) => {
|
{block.items.map((item, i) => {
|
||||||
const surface = cn(
|
const surface = cn(
|
||||||
@@ -375,7 +434,7 @@ const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt })
|
|||||||
InsightsBlock.displayName = 'InsightsBlock';
|
InsightsBlock.displayName = 'InsightsBlock';
|
||||||
|
|
||||||
/** Recommended steps — numbered, because order is the recommendation. */
|
/** Recommended steps — numbered, because order is the recommendation. */
|
||||||
const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const ActionsBlock = React.memo(({ block }: any) => (
|
||||||
<ol className="space-y-2">
|
<ol className="space-y-2">
|
||||||
{block.items.map((item, i) => (
|
{block.items.map((item, i) => (
|
||||||
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
|
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
|
||||||
@@ -394,7 +453,7 @@ const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
|||||||
));
|
));
|
||||||
ActionsBlock.displayName = 'ActionsBlock';
|
ActionsBlock.displayName = 'ActionsBlock';
|
||||||
|
|
||||||
const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const BadgesBlock = React.memo(({ block }: any) => (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{block.items.map((item) => (
|
{block.items.map((item) => (
|
||||||
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
|
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
|
||||||
@@ -405,7 +464,7 @@ const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
|||||||
));
|
));
|
||||||
BadgesBlock.displayName = 'BadgesBlock';
|
BadgesBlock.displayName = 'BadgesBlock';
|
||||||
|
|
||||||
const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const TimelineBlock = React.memo(({ block }: any) => (
|
||||||
<ol className="relative space-y-3">
|
<ol className="relative space-y-3">
|
||||||
{block.items.map((item, i) => (
|
{block.items.map((item, i) => (
|
||||||
<li key={i} className="flex gap-3">
|
<li key={i} className="flex gap-3">
|
||||||
@@ -438,7 +497,7 @@ const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
|||||||
));
|
));
|
||||||
TimelineBlock.displayName = 'TimelineBlock';
|
TimelineBlock.displayName = 'TimelineBlock';
|
||||||
|
|
||||||
const NoteBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const NoteBlock = React.memo(({ block }: any) => (
|
||||||
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
|
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
|
||||||
<Inline value={block.text} />
|
<Inline value={block.text} />
|
||||||
</p>
|
</p>
|
||||||
@@ -455,7 +514,7 @@ NoteBlock.displayName = 'NoteBlock';
|
|||||||
* shape the table does not carry renders nothing rather than something
|
* shape the table does not carry renders nothing rather than something
|
||||||
* improvised.
|
* improvised.
|
||||||
*/
|
*/
|
||||||
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
const SkillSectionBlock = React.memo(({ block }: any) => {
|
||||||
const section = block.section;
|
const section = block.section;
|
||||||
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
|
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
|
||||||
|
|
||||||
@@ -505,7 +564,7 @@ SkillSectionBlock.displayName = 'SkillSectionBlock';
|
|||||||
* - **Not pretend to be finished.** Once approved, the block stays visible
|
* - **Not pretend to be finished.** Once approved, the block stays visible
|
||||||
* and says so. Replacing it with a tick would lose what was agreed to.
|
* and says so. Replacing it with a tick would lose what was agreed to.
|
||||||
*/
|
*/
|
||||||
const ConfirmationBlock = React.memo(/** @param {any} props */ ({ block, onConfirm }) => {
|
const ConfirmationBlock = React.memo(({ block, onConfirm }: any) => {
|
||||||
const [state, setState] = React.useState('pending');
|
const [state, setState] = React.useState('pending');
|
||||||
|
|
||||||
const approve = React.useCallback(() => {
|
const approve = React.useCallback(() => {
|
||||||
@@ -614,7 +673,7 @@ const RENDERERS = {
|
|||||||
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
||||||
* else breathes.
|
* else breathes.
|
||||||
*/
|
*/
|
||||||
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt, onConfirm }) => (
|
export const ResponseDocument = React.memo(({ blocks = [], streaming = false, onPrompt, onConfirm }: any) => (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{blocks.map((block, i) => {
|
{blocks.map((block, i) => {
|
||||||
const Renderer = RENDERERS[block.type];
|
const Renderer = RENDERERS[block.type];
|
||||||
@@ -20,7 +20,7 @@ export const doc = (...blocks) => ({
|
|||||||
export const text = (value) => value && { type: 'text', text: value };
|
export const text = (value) => value && { type: 'text', text: value };
|
||||||
|
|
||||||
/** A section label inside a response. */
|
/** A section label inside a response. */
|
||||||
export const heading = (value, sub) => value && { type: 'heading', text: value, sub };
|
export const heading = (value, sub?) => value && { type: 'heading', text: value, sub };
|
||||||
|
|
||||||
/** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */
|
/** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */
|
||||||
export const kpis = (items) => items?.length && { type: 'kpis', items };
|
export const kpis = (items) => items?.length && { type: 'kpis', items };
|
||||||
@@ -15,7 +15,7 @@ import { PRIVILEGED_EVENTS, activitySignals } from '@/lib/activitySignals';
|
|||||||
|
|
||||||
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
|
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
|
||||||
|
|
||||||
export const plural = (n, word, irregular) =>
|
export const plural = (n, word, irregular?) =>
|
||||||
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
|
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
|
||||||
|
|
||||||
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
|
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
|
||||||
@@ -215,7 +215,7 @@ export function buildFacts({
|
|||||||
),
|
),
|
||||||
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
|
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
|
||||||
avgScore: avg(profiles.filter((p) => p.krow_score > 0).map((p) => p.krow_score)),
|
avgScore: avg(profiles.filter((p) => p.krow_score > 0).map((p) => p.krow_score)),
|
||||||
bands: Object.values(profiles.reduce((acc, p) => {
|
bands: Object.values(profiles.reduce<Record<string, { label: string; count: number }>>((acc, p) => {
|
||||||
const { label } = getScoreBand(p.krow_score || 0);
|
const { label } = getScoreBand(p.krow_score || 0);
|
||||||
acc[label] ||= { label, count: 0 };
|
acc[label] ||= { label, count: 0 };
|
||||||
acc[label].count += 1;
|
acc[label].count += 1;
|
||||||
@@ -241,7 +241,9 @@ export function buildFacts({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupHires = (key) => Object.values(hires.reduce((acc, h) => {
|
const groupHires = (key) => Object.values(hires.reduce<Record<string, {
|
||||||
|
name: string; count: number; scores: any[]; days: any[]; ratings: any[];
|
||||||
|
}>>((acc, h) => {
|
||||||
const name = h[key] || 'Unspecified';
|
const name = h[key] || 'Unspecified';
|
||||||
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
|
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
|
||||||
acc[name].count += 1;
|
acc[name].count += 1;
|
||||||
142
src/components/ai-assistant/language.ts
Normal file
142
src/components/ai-assistant/language.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The language Owliver answers in.
|
||||||
|
*
|
||||||
|
* One list, read by the header's selector and by the provider that sends the
|
||||||
|
* run. A second copy would be two lists that could disagree, and the way that
|
||||||
|
* shows up is a selector offering a language the backend does not answer in.
|
||||||
|
*
|
||||||
|
* The tags are the backend's: `internal/runtime/language.go` holds the same
|
||||||
|
* set, and the string sent here SELECTS a directive there rather than becoming
|
||||||
|
* one. That is deliberate and it is a security boundary, not a style — a tag
|
||||||
|
* this file invented would silently fall back to English rather than being
|
||||||
|
* interpolated into a prompt.
|
||||||
|
*
|
||||||
|
* This is the panel's language, not the product's. Nothing else in the
|
||||||
|
* dashboard is translated, and the selector does not claim to be: it governs
|
||||||
|
* what Owliver writes back, which is the only text here that is generated
|
||||||
|
* rather than designed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The offered languages, in the order the selector shows them. */
|
||||||
|
export const LANGUAGES = [
|
||||||
|
/* English first and English default. Every agent's instructions are written
|
||||||
|
in English, so it is the language that needs no directive at all. */
|
||||||
|
{ tag: 'en', label: 'English' },
|
||||||
|
{ tag: 'es', label: 'Español' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_LANGUAGE = 'en';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the choice is kept.
|
||||||
|
*
|
||||||
|
* `localStorage`, deliberately — not thread state. Somebody who reads in
|
||||||
|
* Spanish reads in Spanish on every page and after every reload; making it
|
||||||
|
* per-conversation would ask them to set it again each time the panel opened,
|
||||||
|
* which is the behaviour of a filter rather than of a preference.
|
||||||
|
*
|
||||||
|
* Not persisted into the thread record either: replaying an old conversation
|
||||||
|
* shows the answers as they were written, and re-labelling them with today's
|
||||||
|
* preference would claim a Spanish answer had been given in English.
|
||||||
|
*/
|
||||||
|
const STORAGE_KEY = 'krow_owliver_language';
|
||||||
|
|
||||||
|
/** Whether `tag` is a language this build offers. */
|
||||||
|
export function isLanguage(tag) {
|
||||||
|
return LANGUAGES.some((l) => l.tag === tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored preference, or English.
|
||||||
|
*
|
||||||
|
* Tolerant of everything storage can do: a tag written by an older build, a
|
||||||
|
* browser that denies access, private mode. The question "which language" must
|
||||||
|
* always have an answer, and an unreadable store means the default rather than
|
||||||
|
* a crash on the panel's first render.
|
||||||
|
*/
|
||||||
|
export function readLanguage() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return isLanguage(stored) ? stored : DEFAULT_LANGUAGE;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_LANGUAGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores the preference. A failure to write is not worth breaking a render. */
|
||||||
|
export function writeLanguage(tag) {
|
||||||
|
try {
|
||||||
|
if (isLanguage(tag)) localStorage.setItem(STORAGE_KEY, tag);
|
||||||
|
} catch {
|
||||||
|
/* Private mode, or storage denied. The selector still works for this
|
||||||
|
session; it just will not be remembered after a reload. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The label for a tag, for the selector and for an aria-label. */
|
||||||
|
export function languageLabel(tag) {
|
||||||
|
return LANGUAGES.find((l) => l.tag === tag)?.label || 'English';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribers to the choice.
|
||||||
|
*
|
||||||
|
* The selector lives in the account menu and the thing it governs lives in the
|
||||||
|
* Owliver panel — two subtrees with no parent between them short of the app
|
||||||
|
* root. A context provider spanning both would be a provider added for one
|
||||||
|
* string; an external store is the smaller change and it also gets cross-tab
|
||||||
|
* sync for free, since `storage` fires in the OTHER tab and nothing else would
|
||||||
|
* have told it.
|
||||||
|
*/
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
/** Cached so `getSnapshot` is cheap and, more importantly, stable. */
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
if (current === null) current = readLanguage();
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
/* Another tab changed it. `storage` does not fire in the tab that wrote, so
|
||||||
|
this handles only the other ones; `setLanguage` notifies this one. */
|
||||||
|
const onStorage = (e) => {
|
||||||
|
if (e.key !== null && e.key !== STORAGE_KEY) return;
|
||||||
|
current = readLanguage();
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
window.removeEventListener('storage', onStorage);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the language everywhere.
|
||||||
|
*
|
||||||
|
* Writes through to storage and notifies every reader in this tab. Ignores a
|
||||||
|
* tag this build does not offer, so a stale menu cannot put the app into a
|
||||||
|
* language the backend will not answer in.
|
||||||
|
*/
|
||||||
|
export function setLanguage(tag) {
|
||||||
|
if (!isLanguage(tag) || tag === snapshot()) return;
|
||||||
|
current = tag;
|
||||||
|
writeLanguage(tag);
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current language, re-rendering the caller when it changes.
|
||||||
|
*
|
||||||
|
* `useSyncExternalStore` rather than state plus an effect: the panel must read
|
||||||
|
* the value that is true at render time, and a value corrected in an effect is
|
||||||
|
* one question answered in the wrong language.
|
||||||
|
*/
|
||||||
|
export function useLanguage() {
|
||||||
|
return React.useSyncExternalStore(subscribe, snapshot, () => DEFAULT_LANGUAGE);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
* capability: string | null, // capability id, or null for free text
|
* capability: string | null, // capability id, or null for free text
|
||||||
* question: string, // what the user typed, or the chip's prompt
|
* question: string, // what the user typed, or the chip's prompt
|
||||||
* facts: object, // dashboard fact sheet (see insights.js)
|
* facts: object, // dashboard fact sheet (see insights.js)
|
||||||
|
* language: string | null, // language tag to answer in (see language.js)
|
||||||
* signal: AbortSignal, // aborts an in-flight response
|
* signal: AbortSignal, // aborts an in-flight response
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
@@ -53,7 +54,22 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
|||||||
return {
|
return {
|
||||||
id: 'agent',
|
id: 'agent',
|
||||||
|
|
||||||
async *stream({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
|
/**
|
||||||
|
* Every snapshot leaves through here, and every snapshot is sanitised.
|
||||||
|
*
|
||||||
|
* The wrapper is the point. Below it there are four ways a response gets
|
||||||
|
* built — streamed deltas, a completed run, a bounded run's trailing
|
||||||
|
* message, and the two failure notes — and only one of them passes through
|
||||||
|
* the markdown parser that removes citation ids. Sanitising at the yield
|
||||||
|
* rather than at each construction means a fifth way, added later, cannot
|
||||||
|
* reintroduce the leak by forgetting a call.
|
||||||
|
*/
|
||||||
|
async *stream(request) {
|
||||||
|
for await (const snapshot of this.run(request)) yield sanitizeBlocks(snapshot);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The run itself. Public only so `stream` can wrap it; call `stream`. */
|
||||||
|
async *run({ question, agent = null, confirmation = null, agentVersion = 0, language = null, signal }) {
|
||||||
/* No agent, no run. The panel resolves which agent covers the page before
|
/* No agent, no run. The panel resolves which agent covers the page before
|
||||||
calling; reaching here without one means the routing layer changed and
|
calling; reaching here without one means the routing layer changed and
|
||||||
this should say so rather than guess at an agent id. */
|
this should say so rather than guess at an agent id. */
|
||||||
@@ -83,6 +99,15 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
|||||||
stops an edit published mid-thread from silently changing which
|
stops an edit published mid-thread from silently changing which
|
||||||
agent is answering. */
|
agent is answering. */
|
||||||
agentVersion: agentVersion || undefined,
|
agentVersion: agentVersion || undefined,
|
||||||
|
/* The language to answer in. Omitted when it is the default, so an
|
||||||
|
English run sends exactly the body it sent before the selector
|
||||||
|
existed — and a deployment that does not know the field yet is
|
||||||
|
unaffected by the common case.
|
||||||
|
|
||||||
|
A tag, never a sentence. The server maps it onto a closed set and
|
||||||
|
an unrecognised one answers in English; the panel cannot write
|
||||||
|
prompt text from here, which is the point of sending a tag. */
|
||||||
|
language: language && language !== 'en' ? language : undefined,
|
||||||
}),
|
}),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@@ -169,7 +194,9 @@ async function* readRunStream(response, signal) {
|
|||||||
|
|
||||||
if (typeof event.delta === 'string') {
|
if (typeof event.delta === 'string') {
|
||||||
text += event.delta;
|
text += event.delta;
|
||||||
yield markdownToBlocks(text);
|
/* Still arriving: the frontier rules apply, so a citation split
|
||||||
|
across two frames is never rendered half-written. */
|
||||||
|
yield markdownToBlocks(text, { partial: true });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (event.run) final = event.run;
|
if (event.run) final = event.run;
|
||||||
@@ -226,6 +253,290 @@ function toBlocks(run) {
|
|||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Citations ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why any of this exists.
|
||||||
|
*
|
||||||
|
* The backend asks the model to cite: `knowledge/context.go` tells it "Each
|
||||||
|
* <source> carries an id: cite it when you use what it says", and the
|
||||||
|
* `knowledge_search` tool repeats it. What neither does is say HOW — so the
|
||||||
|
* model picks a format, and picks a different one on a different day. The ids
|
||||||
|
* themselves are `knowledge_chunks.id`, which are UUIDs, and the model quotes
|
||||||
|
* them whole or truncated to their first block.
|
||||||
|
*
|
||||||
|
* The panel has no citation surface to render any of that into, so whatever
|
||||||
|
* shape the model chose arrives on screen as raw markup. The formats seen so
|
||||||
|
* far are a `<cite>` tag and a markdown link to an empty anchor; the rules
|
||||||
|
* below are written against the SHAPE of an identifier rather than against
|
||||||
|
* either format's syntax, so a third spelling of the same idea is far more
|
||||||
|
* likely to be caught than to be a new bug.
|
||||||
|
*
|
||||||
|
* A citation id is hex and dashes — a UUID or a leading run of one. That is
|
||||||
|
* what makes `10 applications`, `97% coverage` and `113 shifts` safe: decimal
|
||||||
|
* counts in prose are not addresses, are never inside citation syntax, and no
|
||||||
|
* rule here looks at a bare number.
|
||||||
|
*/
|
||||||
|
const CITATION_ID = String.raw`[0-9a-f]{4,}(?:-[0-9a-f]{4,})*`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tag spelling — `<cite id="…">…</cite>`, whatever attributes it carries.
|
||||||
|
*
|
||||||
|
* Matched by TAG NAME, never by id: the ids are minted per run, so a rule
|
||||||
|
* written against the ones in today's output would let tomorrow's through.
|
||||||
|
*/
|
||||||
|
const CITATION_TAG = /<\/?cit(?:e|ation)\b[^>]*>/gi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The link spelling, and the brackets the model wraps a run of them in —
|
||||||
|
* `([337042b3](#), [2b94bc43](#))`.
|
||||||
|
*
|
||||||
|
* Identified by two conditions TOGETHER, never either alone: the target must be
|
||||||
|
* a bare `#` anchor, AND the label must look like an identifier rather than
|
||||||
|
* words. A real link has a real href, a real anchor link has a destination
|
||||||
|
* after the `#`, and a link a person would click has a label they could read.
|
||||||
|
* Requiring both is what keeps `[staffing policy](#staffing)`, `[Read more](/docs)`
|
||||||
|
* and even `[Read more](#)` on screen.
|
||||||
|
*
|
||||||
|
* The group is removed whole rather than link by link, because removing them
|
||||||
|
* one at a time leaves `(, )` behind — which reads worse than the ids did.
|
||||||
|
*/
|
||||||
|
const CITATION_GROUP = new RegExp(
|
||||||
|
String.raw`\s*\(\s*\[${CITATION_ID}\]\(#\)(?:\s*,\s*\[${CITATION_ID}\]\(#\))*\s*\)`,
|
||||||
|
'gi'
|
||||||
|
);
|
||||||
|
const CITATION_LINK = new RegExp(String.raw`\s*\[${CITATION_ID}\]\(#\)`, 'gi');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prose spelling: the model narrating the attribute rather than marking it
|
||||||
|
* up — `(id `f34e8ef0-…`)`, `(reference `…`)`, `(source: `…`)`.
|
||||||
|
*
|
||||||
|
* Why this exists is the same reason the other two do. `context.go` hands the
|
||||||
|
* model `<source id="…">` and tells it to cite the id without saying how, so
|
||||||
|
* the model reaches for whatever syntax feels natural that day. This one is not
|
||||||
|
* markup at all — it is the id written out in a parenthesis, which is why no
|
||||||
|
* tag rule and no link rule saw it.
|
||||||
|
*
|
||||||
|
* THE WRAPPER IS WHAT IDENTIFIES IT, NEVER THE ID. That distinction is the
|
||||||
|
* whole rule and it has to survive future edits: a worker's record id is the
|
||||||
|
* same shape as a chunk id, so a rule that recognised ids rather than wrappers
|
||||||
|
* would delete real data off a profile. `Worker ID: f34e8ef0-…` has no
|
||||||
|
* parenthesis, so nothing here reaches it; `(id `f34e8ef0-…`)` is a
|
||||||
|
* parenthesis containing a reference word and nothing but ids, which is not a
|
||||||
|
* shape prose takes for any other reason.
|
||||||
|
*
|
||||||
|
* Backticks are optional on each side independently, because a model that opens
|
||||||
|
* a code span and forgets to close it before the bracket must not defeat this.
|
||||||
|
*/
|
||||||
|
const REF_WORD = String.raw`(?:ids?|refs?|references?|sources?|citations?)`;
|
||||||
|
const TICKED_ID = String.raw`\x60?${CITATION_ID}\x60?`;
|
||||||
|
const CITATION_LABELLED = new RegExp(
|
||||||
|
String.raw`\s*\(\s*${REF_WORD}\s*:?\s*${TICKED_ID}(?:\s*,\s*${TICKED_ID})*\s*\)`,
|
||||||
|
'gi'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids in square brackets with nothing else in them — `[uuid]`, `[uuid, uuid]`.
|
||||||
|
*
|
||||||
|
* A markdown link is `[label](target)`; a bracket holding only identifiers is
|
||||||
|
* not a link and is not something prose does. The lookahead leaves anything
|
||||||
|
* followed by `(` to the link rules, so a genuine link whose label happens to
|
||||||
|
* be a reference number keeps its destination and stays on screen.
|
||||||
|
*/
|
||||||
|
/* Stricter than CITATION_ID, and only here. A bare bracket has no reference
|
||||||
|
word to disambiguate it, so the id itself has to carry the evidence: at least
|
||||||
|
eight hex characters, or a dashed group. Without that `[2026]` is four hex
|
||||||
|
digits and a year in brackets would disappear from an answer. */
|
||||||
|
const BRACKETABLE_ID = String.raw`(?:[0-9a-f]{8,}|[0-9a-f]{4,}(?:-[0-9a-f]{4,})+)`;
|
||||||
|
const CITATION_BRACKETED = new RegExp(
|
||||||
|
String.raw`\s*\[\s*\x60?${BRACKETABLE_ID}\x60?(?:\s*,\s*\x60?${BRACKETABLE_ID}\x60?)*\s*\](?!\()`,
|
||||||
|
'gi'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whatever is still arriving at the end of the text.
|
||||||
|
*
|
||||||
|
* The streaming half of the problem, and it is a real one rather than a
|
||||||
|
* theoretical one: `readRunStream` re-parses the WHOLE accumulated answer on
|
||||||
|
* every delta, so a citation split across two frames is a state the reader can
|
||||||
|
* see. `…before a first shift ([3370` renders for as long as the next delta
|
||||||
|
* takes to arrive.
|
||||||
|
*
|
||||||
|
* Both rules are anchored to the end of the text, so they can only ever
|
||||||
|
* describe the frontier of the stream and never something the answer has
|
||||||
|
* already moved past. The fragment is held back until it completes, at which
|
||||||
|
* point the rules above remove it properly — which is buffering, expressed as
|
||||||
|
* a parse rather than as a second copy of the text.
|
||||||
|
*/
|
||||||
|
const CITATION_TAG_PARTIAL = /<\/?[a-z]*$|<\/?cit(?:e|ation)\b[^>]*$/i;
|
||||||
|
const CITATION_LINK_PARTIAL = new RegExp([
|
||||||
|
/* An open bracket holding at least one COMPLETE citation and not yet closed:
|
||||||
|
`…shift ([337042b3](#)` and `…shift ([337042b3](#), `. Without this the
|
||||||
|
complete link is removed by the rule above and the `(` is stranded. */
|
||||||
|
String.raw`\s*\((?:\s*\[${CITATION_ID}\]\(#\)\s*,?)+\s*(?:\[[0-9a-f-]*(?:\](?:\(#?)?)?)?$`,
|
||||||
|
/* One part-written, with or without its bracket: `([3370`, `[337042b3`,
|
||||||
|
`[337042b3](`, `[337042b3](#`. */
|
||||||
|
String.raw`\s*\(?\s*\[[0-9a-f-]*(?:\](?:\(#?)?)?$`,
|
||||||
|
].join('|'), 'i');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same two, part-written.
|
||||||
|
*
|
||||||
|
* `Policy (id `f34e8ef0-9a01-5921` is a state the reader can see, because the
|
||||||
|
* stream re-parses everything on every delta. Both are anchored to the end, so
|
||||||
|
* they describe only the frontier.
|
||||||
|
*
|
||||||
|
* The labelled rule accepts any short leading word rather than only a reference
|
||||||
|
* word, because `(i` and `(id` are prefixes of one and `(` is a prefix of
|
||||||
|
* everything. The cost is that an ordinary parenthetical is held back for the
|
||||||
|
* frames between its bracket and its first non-hex character —
|
||||||
|
* `Omar Haddad (hired 2026-01-0` waits, `…(hired 2026-01-04) starts` does not.
|
||||||
|
* A parenthesis arriving a frame late is not something a reader can notice; a
|
||||||
|
* half-written reference id is exactly what they reported.
|
||||||
|
*/
|
||||||
|
const CITATION_LABELLED_PARTIAL = new RegExp(
|
||||||
|
String.raw`\s*\((?:[a-z]{0,12}[\s:]*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?)?$`,
|
||||||
|
'i'
|
||||||
|
);
|
||||||
|
const CITATION_BRACKETED_PARTIAL = new RegExp(
|
||||||
|
String.raw`\s*\[\s*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?$`,
|
||||||
|
'i'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a lifted citation leaves behind.
|
||||||
|
*
|
||||||
|
* `check ()`, `check (, )`, `check .` — the wrapper is gone and its punctuation
|
||||||
|
* is not, and a sentence ending in an empty bracket reads as broken markup
|
||||||
|
* rather than as a clean sentence. Applied after the removals, never before.
|
||||||
|
*/
|
||||||
|
const EMPTY_PARENS = /\s*\(\s*[,;]*\s*\)/g;
|
||||||
|
const SPACE_BEFORE_PUNCTUATION = /\s+([.,;:!?])/g;
|
||||||
|
const DOUBLED_SPACES = / {2,}/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes citation markup, keeping the sentence inside it.
|
||||||
|
*
|
||||||
|
* The wrapper is addressing, not content — it tells a client which retrieved
|
||||||
|
* chunk a claim came from — and with nowhere to render it the honest move is to
|
||||||
|
* show the claim and drop the envelope. The backend's citation metadata is
|
||||||
|
* untouched: it is still on the run, still in the trajectory, and this only
|
||||||
|
* decides what reaches a reader.
|
||||||
|
*
|
||||||
|
* Content is never altered, only the wrapper around it, so markdown inside a
|
||||||
|
* citation — bold, a bullet, a table row — parses exactly as it would have
|
||||||
|
* unwrapped.
|
||||||
|
*
|
||||||
|
* If the panel ever grows a real citation affordance, this is the seam: parse
|
||||||
|
* the ids out here into a block the renderer can draw, rather than discarding
|
||||||
|
* them. Nothing else has to move.
|
||||||
|
*/
|
||||||
|
export function stripCitations(markdown, { partial = false } = {}) {
|
||||||
|
let out = String(markdown ?? '')
|
||||||
|
.replace(CITATION_TAG, '')
|
||||||
|
.replace(CITATION_GROUP, '')
|
||||||
|
.replace(CITATION_LABELLED, '')
|
||||||
|
.replace(CITATION_BRACKETED, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frontier rules, and ONLY while there is a frontier.
|
||||||
|
*
|
||||||
|
* They describe something that is still being written, so they are wrong to
|
||||||
|
* apply to a finished answer — and quietly so. `…and note [12]` ends in a
|
||||||
|
* bracket holding two hex characters, which is indistinguishable from the
|
||||||
|
* first two characters of an id still arriving. Mid-stream, holding it back
|
||||||
|
* for a frame is right. At the end of a completed answer there is nothing
|
||||||
|
* more coming, the bracket is all there will ever be, and removing it deletes
|
||||||
|
* a footnote marker from the reader's answer.
|
||||||
|
*
|
||||||
|
* The caller knows which it is: `readRunStream` passes `partial` on a delta
|
||||||
|
* and not on the final snapshot. That is the only place the distinction
|
||||||
|
* exists, so it is the only place it can be made.
|
||||||
|
*
|
||||||
|
* Order is load-bearing. `…shift ([337042b3](#),` is a COMPLETE link inside
|
||||||
|
* an unclosed bracket — strip the link first and `(,` is left on screen,
|
||||||
|
* which is the broken bracket this exists to prevent. Matching the
|
||||||
|
* unterminated group first takes the whole fragment.
|
||||||
|
*/
|
||||||
|
if (partial) {
|
||||||
|
out = out
|
||||||
|
.replace(CITATION_TAG_PARTIAL, '')
|
||||||
|
.replace(CITATION_LINK_PARTIAL, '')
|
||||||
|
.replace(CITATION_LABELLED_PARTIAL, '')
|
||||||
|
.replace(CITATION_BRACKETED_PARTIAL, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
.replace(CITATION_LINK, '')
|
||||||
|
.replace(EMPTY_PARENS, '')
|
||||||
|
.replace(SPACE_BEFORE_PUNCTUATION, '$1')
|
||||||
|
.replace(DOUBLED_SPACES, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys that carry text a person reads, on any block.
|
||||||
|
*
|
||||||
|
* An allow-list rather than a deny-list, because the two mistakes do not cost
|
||||||
|
* the same: missing a display field leaks an id, while sanitising an address
|
||||||
|
* field would corrupt a confirmation token, a route or a record id and break
|
||||||
|
* what it points at. A new block type gets its display keys covered for free; a
|
||||||
|
* new addressing key is safe by default.
|
||||||
|
*/
|
||||||
|
const DISPLAY_KEYS = new Set([
|
||||||
|
'text', 'sub', 'label', 'title', 'summary', 'caption',
|
||||||
|
'description', 'detail', 'note', 'heading', 'hint',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Keys whose value is a list of sentences rather than one. */
|
||||||
|
const DISPLAY_LISTS = new Set(['items', 'warnings', 'details', 'lines', 'rows']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Citation-proofs a whole response, whatever shape it arrived in.
|
||||||
|
*
|
||||||
|
* `markdownToBlocks` strips the model's markdown, and for a completed answer
|
||||||
|
* that is the whole story. It is NOT the whole story for the response: the same
|
||||||
|
* provider also emits `note(run.message)` when a run did not complete,
|
||||||
|
* `note(event.error.message)` when the stream fails, and `confirmation(payload)`
|
||||||
|
* carrying server wording composed around model-supplied arguments. None of
|
||||||
|
* those go through the markdown parser, so each was a way for an id to reach
|
||||||
|
* the DOM without passing the one place that removes them.
|
||||||
|
*
|
||||||
|
* Rather than a `stripCitations` call at each — three sites today, and a fourth
|
||||||
|
* the next time the provider learns to say something — every block the agent
|
||||||
|
* provider yields goes through here.
|
||||||
|
*
|
||||||
|
* Walks recursively so nested shapes are reached (a table's rows, a
|
||||||
|
* confirmation's warnings, an insight's items) and touches only the keys above:
|
||||||
|
* `token`, `id`, `route`, `to` and everything else addressing-like is left
|
||||||
|
* exactly as the server sent it.
|
||||||
|
*/
|
||||||
|
export function sanitizeBlocks(blocks) {
|
||||||
|
return (blocks || []).map((block) => sanitizeValue(block, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeValue(value, key) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return DISPLAY_KEYS.has(key) || DISPLAY_LISTS.has(key) ? stripCitations(value) : value;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
/* The key travels into the elements, strings and objects alike. A string in
|
||||||
|
`items` is display text; an object in `rows` is a row, and only the key
|
||||||
|
says so — its own cell keys are positional (`c0`, `c1`) and carry no
|
||||||
|
meaning at all. An object in `columns` still defers to its own keys. */
|
||||||
|
return value.map((entry) => sanitizeValue(entry, key));
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(value)) {
|
||||||
|
/* A table row is `{ c0: 'Applied', c1: '4' }` — the cell keys are
|
||||||
|
positional and carry no meaning, so the row itself marks them. */
|
||||||
|
out[k] = key === 'rows' && typeof v === 'string' ? stripCitations(v) : sanitizeValue(v, k);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns a model's markdown into the block vocabulary the panel already renders.
|
* Turns a model's markdown into the block vocabulary the panel already renders.
|
||||||
*
|
*
|
||||||
@@ -247,8 +558,8 @@ function toBlocks(run) {
|
|||||||
* parser would be a large dependency in exchange for handling footnotes nobody
|
* parser would be a large dependency in exchange for handling footnotes nobody
|
||||||
* writes.
|
* writes.
|
||||||
*/
|
*/
|
||||||
export function markdownToBlocks(markdown) {
|
export function markdownToBlocks(markdown, { partial = false } = {}) {
|
||||||
const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
|
const lines = stripCitations(markdown, { partial }).replace(/\r\n/g, '\n').split('\n');
|
||||||
const blocks = [];
|
const blocks = [];
|
||||||
let paragraph = [];
|
let paragraph = [];
|
||||||
let listItems = null;
|
let listItems = null;
|
||||||
@@ -353,7 +664,15 @@ function parseTable(lines, start) {
|
|||||||
* left alone — those go through `Inline`, which renders bold properly.
|
* left alone — those go through `Inline`, which renders bold properly.
|
||||||
*/
|
*/
|
||||||
function stripInline(value) {
|
function stripInline(value) {
|
||||||
return String(value).replace(/\*\*(.+?)\*\*/g, '$1').replace(/`(.+?)`/g, '$1').trim();
|
return String(value)
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||||
|
.replace(/`(.+?)`/g, '$1')
|
||||||
|
/* A link keeps its label and loses its target. Headings and cells are drawn
|
||||||
|
as plain strings by their components, so an anchor cannot survive here —
|
||||||
|
and the label alone reads correctly, where the raw `[label](href)` does
|
||||||
|
not. `Inline` renders the real thing everywhere a link CAN be one. */
|
||||||
|
.replace(/\[([^\]\n]+)\]\([^)\s]*\)/g, '$1')
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -438,7 +757,7 @@ export function createAssistantProvider() {
|
|||||||
export function createUnconfiguredProvider() {
|
export function createUnconfiguredProvider() {
|
||||||
return {
|
return {
|
||||||
id: 'unconfigured',
|
id: 'unconfigured',
|
||||||
// eslint-disable-next-line require-yield
|
|
||||||
async *stream() {
|
async *stream() {
|
||||||
yield [note(
|
yield [note(
|
||||||
'Owliver is not configured on this deployment. Set VITE_AGENT_API and give the '
|
'Owliver is not configured on this deployment. Set VITE_AGENT_API and give the '
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { doc, heading, insights, list, note, skillSection, text } from './blocks';
|
import { doc, heading, insights, list, note, skillSection, text } from './blocks';
|
||||||
|
import { resolveUiEdit } from './uiEdit';
|
||||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||||
import { poolFor } from '@/lib/workforce';
|
import { poolFor } from '@/lib/workforce';
|
||||||
import { matchSkill } from '@/lib/skills/registry';
|
import { matchSkill } from '@/lib/skills/registry';
|
||||||
@@ -23,7 +24,8 @@ import {
|
|||||||
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
|
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
|
||||||
extractSkillName, findCourseByName,
|
extractSkillName, findCourseByName,
|
||||||
} from '@/lib/skills/actions';
|
} from '@/lib/skills/actions';
|
||||||
import { beginPositionFlow } from '@/lib/skills/positionFlow';
|
import { beginFlow } from '@/lib/skills/conversationFlow';
|
||||||
|
import { flowFor } from '@/lib/skills/flows';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intent routing — deciding whether a question belongs to the page you are on.
|
* Intent routing — deciding whether a question belongs to the page you are on.
|
||||||
@@ -633,7 +635,7 @@ function declaredAnswer({ skill, capability, question, skillContext }) {
|
|||||||
*/
|
*/
|
||||||
function resolveSkill({
|
function resolveSkill({
|
||||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||||
skillContext = null,
|
companies = [], postings = null, skillContext = null,
|
||||||
}) {
|
}) {
|
||||||
/**
|
/**
|
||||||
* One question, one skill, then one way of answering it.
|
* One question, one skill, then one way of answering it.
|
||||||
@@ -728,15 +730,21 @@ function resolveSkill({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create Position is collected in the conversation, not in a form.
|
* A conversational skill is collected in the chat, not in a form.
|
||||||
*
|
*
|
||||||
* Nothing opens and nothing is navigated to: the skill's questions come back
|
* Nothing opens and nothing is navigated to: the skill's questions come back
|
||||||
* as a reply and its answers as chips, and the position is written at the end
|
* as a reply and its answers as chips, and the record is written at the end
|
||||||
* from what the conversation gathered. `flow` is the state that turn carries
|
* from what the conversation gathered. `flow` is the state that turn carries
|
||||||
* forward — the panel keeps it and feeds the next answer back in.
|
* forward — the panel keeps it and feeds the next answer back in.
|
||||||
|
*
|
||||||
|
* Which conversation is the SKILL'S OWN `flow:` declaration, resolved through
|
||||||
|
* `FLOWS`. This used to be `if (skill.id === 'create-position')`, which made a
|
||||||
|
* second conversational skill a change to the router rather than a file on
|
||||||
|
* disk — precisely the `if agent_key == ...` shape §2's I6 rules out.
|
||||||
*/
|
*/
|
||||||
if (skill.id === 'create-position') {
|
const registry = flowFor(skill);
|
||||||
return { kind: 'skill', skill, ...beginPositionFlow({ question, skill, roles }) };
|
if (registry) {
|
||||||
|
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies, postings } }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -855,6 +863,11 @@ function resolveDraftAction(question, workforce, positionId = null) {
|
|||||||
export function resolveIntent({
|
export function resolveIntent({
|
||||||
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
||||||
courses = [], workforce = null, skillContext = null,
|
courses = [], workforce = null, skillContext = null,
|
||||||
|
/* The clients this organization already staffs for, offered as chips on the
|
||||||
|
company question. Read off the postings the caller can already see, so it
|
||||||
|
expands nobody's view — see the `@companies` note in flows/position.js. */
|
||||||
|
companies = [],
|
||||||
|
postings = null,
|
||||||
/**
|
/**
|
||||||
* The active agent, and where the reader is.
|
* The active agent, and where the reader is.
|
||||||
*
|
*
|
||||||
@@ -869,6 +882,14 @@ export function resolveIntent({
|
|||||||
was one. Only the draft flow reads it; a typed question carries none and
|
was one. Only the draft flow reads it; a typed question carries none and
|
||||||
resolves exactly as it always did. */
|
resolves exactly as it always did. */
|
||||||
positionId = null,
|
positionId = null,
|
||||||
|
/**
|
||||||
|
* The layout session for this page, when there is one.
|
||||||
|
*
|
||||||
|
* Carries the tree on screen and whether something is already being
|
||||||
|
* previewed. Absent — or on a page that composes no tree — every branch below
|
||||||
|
* resolves exactly as it did before this existed.
|
||||||
|
*/
|
||||||
|
ui = null,
|
||||||
}) {
|
}) {
|
||||||
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
|
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
|
||||||
|
|
||||||
@@ -900,9 +921,21 @@ export function resolveIntent({
|
|||||||
const draftIntent = resolveDraftAction(question, workforce || { positions: [] }, positionId);
|
const draftIntent = resolveDraftAction(question, workforce || { positions: [] }, positionId);
|
||||||
if (draftIntent) return draftIntent;
|
if (draftIntent) return draftIntent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1b. Changing the page itself.
|
||||||
|
*
|
||||||
|
* Ahead of the skills because a request to hide a section is about the
|
||||||
|
* interface, and a skill trigger reading the same words would answer about
|
||||||
|
* the data behind it. Tightly gated: `resolveUiEdit` returns null unless the
|
||||||
|
* page composes a tree AND the words name something on it, a registered
|
||||||
|
* panel type, or the layout — so an ordinary question is never taken.
|
||||||
|
*/
|
||||||
|
const uiIntent = resolveUiEdit({ question, ui });
|
||||||
|
if (uiIntent) return uiIntent;
|
||||||
|
|
||||||
/* 2. Current page skills — specific triggers, ahead of the general reader. */
|
/* 2. Current page skills — specific triggers, ahead of the general reader. */
|
||||||
const skill = resolveSkill({
|
const skill = resolveSkill({
|
||||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies, postings,
|
||||||
/* The envelope travels beside the collections rather than replacing them:
|
/* The envelope travels beside the collections rather than replacing them:
|
||||||
a resolver reads records, and the envelope says where the reader is. A
|
a resolver reads records, and the envelope says where the reader is. A
|
||||||
source that needs a position still finds it exactly where it always was. */
|
source that needs a position still finds it exactly where it always was. */
|
||||||
224
src/components/ai-assistant/uiEdit.ts
Normal file
224
src/components/ai-assistant/uiEdit.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { doc, list, note, text } from './blocks';
|
||||||
|
import { matchUiEdit } from '@/lib/ui/intent';
|
||||||
|
import { outlineTree } from '@/lib/ui/inspect';
|
||||||
|
import { dataSourceLabel } from '@/lib/skills/surfaces';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owliver's half of a layout change.
|
||||||
|
*
|
||||||
|
* Turns a request into an intent the panel can act on, and into the words that
|
||||||
|
* go back. The understanding itself is in `lib/ui/intent.js`; this decides what
|
||||||
|
* to say about it.
|
||||||
|
*
|
||||||
|
* Every outcome is one of four kinds, and the split matters:
|
||||||
|
*
|
||||||
|
* - `ui-preview` an operation to show, not to keep
|
||||||
|
* - `ui-apply` / `ui-discard` acting on what is already shown
|
||||||
|
* - `ui-answer` a question back, or a refusal — nothing changes
|
||||||
|
*
|
||||||
|
* A preview is never applied in the same turn. The person asked for a change;
|
||||||
|
* they have not yet seen it, and agreeing to something unseen is not agreement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The chips offered while something is being previewed. */
|
||||||
|
const PREVIEW_CHIPS = [
|
||||||
|
{ label: 'Apply', prompt: 'Apply the layout change' },
|
||||||
|
{ label: 'Discard', prompt: 'Discard the layout change' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a layout request.
|
||||||
|
*
|
||||||
|
* Returns null for anything that is not one, which is most of what is typed —
|
||||||
|
* and returning null is what leaves every existing Owliver answer exactly as it
|
||||||
|
* was. The gate is in `matchUiEdit`: a verb alone is never enough.
|
||||||
|
*/
|
||||||
|
export function resolveUiEdit({ question, ui }) {
|
||||||
|
if (!ui?.available) return null;
|
||||||
|
|
||||||
|
const match = matchUiEdit(question, {
|
||||||
|
tree: ui.tree,
|
||||||
|
registry: ui.registry,
|
||||||
|
role: ui.role,
|
||||||
|
previewing: ui.previewing,
|
||||||
|
/* Which page this is. Owliver may only offer, and only accept, what this
|
||||||
|
page can actually hold — the same scope the visual editor's picker uses,
|
||||||
|
so the two can never disagree about what is addable here. */
|
||||||
|
page: ui.page,
|
||||||
|
/* The node this conversation last changed. Nothing is remembered inside the
|
||||||
|
matcher: continuity is a fact the caller holds and passes in. */
|
||||||
|
focus: ui.focus || null,
|
||||||
|
});
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
switch (match.kind) {
|
||||||
|
case 'inspect':
|
||||||
|
return { kind: 'ui-answer', doc: describe(ui.tree, ui.registry) };
|
||||||
|
|
||||||
|
case 'apply':
|
||||||
|
return {
|
||||||
|
kind: 'ui-apply',
|
||||||
|
doc: doc(text('Saved. This page will look like this the next time you open it.')),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'discard':
|
||||||
|
return {
|
||||||
|
kind: 'ui-discard',
|
||||||
|
doc: doc(text('Put back the way it was. Nothing was saved.')),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'plan':
|
||||||
|
return {
|
||||||
|
kind: 'ui-preview',
|
||||||
|
op: match.op,
|
||||||
|
doc: doc(
|
||||||
|
text(`${match.summary}. This is a preview — nothing is saved yet.`),
|
||||||
|
note('Choose Apply to keep it, or Discard to put it back.')
|
||||||
|
),
|
||||||
|
followUp: PREVIEW_CHIPS,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* More than one thing fits.
|
||||||
|
*
|
||||||
|
* Named back rather than guessed at. Editing the wrong section while
|
||||||
|
* somebody is looking at another one is the failure the whole target
|
||||||
|
* resolver exists to avoid, and a coin toss here would reintroduce it.
|
||||||
|
*/
|
||||||
|
case 'ambiguous':
|
||||||
|
return {
|
||||||
|
kind: 'ui-answer',
|
||||||
|
doc: doc(
|
||||||
|
text('More than one part of this page fits that. Which did you mean?'),
|
||||||
|
list(match.candidates.map((node) => `${node.title || node.label} (${node.id})`))
|
||||||
|
),
|
||||||
|
followUp: match.candidates.slice(0, 3).map((node) => ({
|
||||||
|
label: node.title || node.label,
|
||||||
|
prompt: `${node.id}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'unknown':
|
||||||
|
return {
|
||||||
|
kind: 'ui-answer',
|
||||||
|
doc: doc(
|
||||||
|
text('I could not find that on this page.'),
|
||||||
|
note('Ask what is on this page to see what can be changed.')
|
||||||
|
),
|
||||||
|
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A type nobody has registered. Offered the real ones rather than invented. */
|
||||||
|
case 'unknown-type':
|
||||||
|
return {
|
||||||
|
kind: 'ui-answer',
|
||||||
|
doc: doc(
|
||||||
|
text('I do not have that kind of panel.'),
|
||||||
|
text(`I can use: ${match.offered.join(', ')}.`)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shape with no reading named.
|
||||||
|
*
|
||||||
|
* The one place a data source could be invented, and the place it is most
|
||||||
|
* firmly refused: the choices come from the closed vocabulary, and the
|
||||||
|
* person picks.
|
||||||
|
*/
|
||||||
|
case 'needs-source':
|
||||||
|
return {
|
||||||
|
kind: 'ui-answer',
|
||||||
|
doc: doc(
|
||||||
|
text(`What should the ${match.type.label} show?`),
|
||||||
|
list(match.options.map(dataSourceLabel))
|
||||||
|
),
|
||||||
|
followUp: match.options.slice(0, 3).map((id) => ({
|
||||||
|
label: dataSourceLabel(id),
|
||||||
|
prompt: `Add a ${match.type.label} showing ${dataSourceLabel(id)}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asked to apply or discard with nothing being previewed.
|
||||||
|
*
|
||||||
|
* Answered here rather than left to fall through, because falling through
|
||||||
|
* sent the panel's own chip text to the model, which replied — correctly
|
||||||
|
* for what it is — that layout changes are not in its scope. The honest
|
||||||
|
* answer is that there is nothing to act on.
|
||||||
|
*/
|
||||||
|
case 'nothing-previewed':
|
||||||
|
return {
|
||||||
|
kind: 'ui-answer',
|
||||||
|
doc: doc(
|
||||||
|
text(match.op === 'apply'
|
||||||
|
? 'There is nothing to apply — no layout change is being previewed.'
|
||||||
|
: 'There is nothing to discard — no layout change is being previewed.'),
|
||||||
|
note('Ask what is on this page to see what can be changed.')
|
||||||
|
),
|
||||||
|
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Understood, and not possible — with the reason and the way forward.
|
||||||
|
*
|
||||||
|
* A refusal that only says no leaves a person guessing at a vocabulary they
|
||||||
|
* cannot see. When the engine knows what this reading *could* be drawn as,
|
||||||
|
* it says so and offers the choices as chips, so "no, but here" costs one
|
||||||
|
* click rather than another round of guessing.
|
||||||
|
*/
|
||||||
|
case 'refused': {
|
||||||
|
const offered = match.alternatives || [];
|
||||||
|
return {
|
||||||
|
kind: 'ui-answer',
|
||||||
|
doc: offered.length
|
||||||
|
? doc(
|
||||||
|
text(match.message),
|
||||||
|
text(`It can be shown as: ${offered.map((o) => o.label).join(', ')}.`)
|
||||||
|
)
|
||||||
|
: doc(text(match.message)),
|
||||||
|
followUp: offered.slice(0, 3).map((option) => ({
|
||||||
|
label: option.label,
|
||||||
|
prompt: `Show ${match.node?.title || match.node?.label || 'it'} as a ${option.label}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every way this reading could honestly be drawn.
|
||||||
|
*
|
||||||
|
* The options are the registry's answer, not a suggestion: each is a
|
||||||
|
* component the application ships, each will draw this node's own figures,
|
||||||
|
* and choosing one produces exactly the operation `planReplace` would have
|
||||||
|
* produced from the same words. Nothing here is generated.
|
||||||
|
*/
|
||||||
|
case 'options':
|
||||||
|
return {
|
||||||
|
kind: 'ui-answer',
|
||||||
|
doc: doc(
|
||||||
|
text(`${match.subject} can be drawn these ways. Each one uses the same figures.`),
|
||||||
|
list(match.options.map((option) => `${option.label} — ${option.summary}`)),
|
||||||
|
note('Pick one to preview it. Nothing is saved until you apply.')
|
||||||
|
),
|
||||||
|
followUp: match.options.map((option) => ({
|
||||||
|
label: option.label,
|
||||||
|
prompt: `Show ${match.node.title || match.node.label} as a ${option.label}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is on the page, as a reading rather than a change. */
|
||||||
|
function describe(tree, registry) {
|
||||||
|
const lines = outlineTree(tree, { registry });
|
||||||
|
if (!lines.length) {
|
||||||
|
return doc(text('This page is not one I can rearrange yet.'));
|
||||||
|
}
|
||||||
|
return doc(
|
||||||
|
text('This page is made of these parts. You can hide, show or reorder any of them.'),
|
||||||
|
list(lines),
|
||||||
|
note('Say for example "hide the audit log" or "move the timeline to the top".')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,9 +6,8 @@ import {
|
|||||||
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
||||||
} from '@/lib/krowHooks';
|
} from '@/lib/krowHooks';
|
||||||
import { skillsForContext } from '@/lib/skills/registry';
|
import { skillsForContext } from '@/lib/skills/registry';
|
||||||
import {
|
import { advanceFlow } from '@/lib/skills/conversationFlow';
|
||||||
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
import { flowFor } from '@/lib/skills/flows';
|
||||||
} from '@/lib/skills/positionFlow';
|
|
||||||
import {
|
import {
|
||||||
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
|
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
|
||||||
publishedReply, weightsSetReply, weightsUnchangedReply,
|
publishedReply, weightsSetReply, weightsUnchangedReply,
|
||||||
@@ -26,6 +25,7 @@ import { agentRequest } from '@/lib/agents/runtime';
|
|||||||
import { createAssistantProvider } from './provider';
|
import { createAssistantProvider } from './provider';
|
||||||
import { preferAgent, resolveIntent } from './routing';
|
import { preferAgent, resolveIntent } from './routing';
|
||||||
import { doc, text as textBlock, toSnapshots } from './blocks';
|
import { doc, text as textBlock, toSnapshots } from './blocks';
|
||||||
|
import { DEFAULT_LANGUAGE } from './language';
|
||||||
|
|
||||||
/** One provider instance for the app's lifetime. */
|
/** One provider instance for the app's lifetime. */
|
||||||
const provider = createAssistantProvider();
|
const provider = createAssistantProvider();
|
||||||
@@ -186,6 +186,88 @@ function normalizeDraftChip(chip) {
|
|||||||
return chip;
|
return chip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One question, reduced to what it asks.
|
||||||
|
*
|
||||||
|
* Case, surrounding space and a trailing question mark are not differences, so
|
||||||
|
* "What should I do next?" and "what should i do next" are one question and are
|
||||||
|
* not offered twice.
|
||||||
|
*/
|
||||||
|
const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/[?.!]+$/, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chips to offer after an answer: what this conversation has not covered.
|
||||||
|
*
|
||||||
|
* Two rules, and the second is the one that matters. The suggestions are ranked
|
||||||
|
* by the SERVER against the question just asked — the panel does not decide what
|
||||||
|
* is worth asking, it only decides what has already been said — and then what
|
||||||
|
* this thread has already covered is removed.
|
||||||
|
*
|
||||||
|
* Without that second rule the row repeats. A page carries a handful of intents
|
||||||
|
* and the top of that list barely moves between turns, so the same three chips
|
||||||
|
* come back after every answer, including the one the reader has just pressed.
|
||||||
|
*
|
||||||
|
* ASKED AND OFFERED ARE NOT THE SAME THING, and treating them as one is what
|
||||||
|
* made the row appear exactly once per conversation. Every chip ever *shown*
|
||||||
|
* was being banned permanently, in a set that only grew; since the server keeps
|
||||||
|
* returning the top of the same small catalogue, the second turn's suggestions
|
||||||
|
* were all already in it, the filter emptied the list, and an empty list means
|
||||||
|
* no row. Every turn after the first was silent, whatever was asked.
|
||||||
|
*
|
||||||
|
* So the two are separated. A question this thread actually PUT is excluded for
|
||||||
|
* good — it has an answer on screen, and offering to repeat it is not a follow-
|
||||||
|
* up. A chip merely DISPLAYED and passed over is held back only from the turn
|
||||||
|
* directly after it, which is enough to stop the row redrawing verbatim under
|
||||||
|
* consecutive answers; beyond that it is offerable again, because a reader
|
||||||
|
* ignoring a suggestion is not the same as having covered it.
|
||||||
|
*
|
||||||
|
* There is NO fallback to the page's own ranking, and that is the correction a
|
||||||
|
* live run forced. Asking "Summarize hiring activity" matches nothing in the
|
||||||
|
* catalogue, so nothing was excluded, so the fallback returned the page's top
|
||||||
|
* three — and the reader got "How healthy is the platform right now?" under an
|
||||||
|
* answer about hiring activity, which is the generic-catalogue behaviour this
|
||||||
|
* function exists to end. A page ranking is what to ask on a PAGE; it is not a
|
||||||
|
* follow-up to anything. When the conversation has no next question, the honest
|
||||||
|
* answer is none.
|
||||||
|
*/
|
||||||
|
export async function nextSteps({ question, history, refresh }) {
|
||||||
|
if (!refresh) return undefined;
|
||||||
|
|
||||||
|
/* Permanent: what this thread has actually asked, including the question
|
||||||
|
being answered right now. */
|
||||||
|
const asked = new Set([asQuestion(question)]);
|
||||||
|
for (const message of history) {
|
||||||
|
if (message.role === 'user') asked.add(asQuestion(message.text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transient: the row under the answer directly above this one, and only
|
||||||
|
that row. The immediately preceding assistant turn, whether or not it
|
||||||
|
carried chips — if it offered none there is nothing to hold back, and a
|
||||||
|
suggestion last seen further up the thread has had an answer between it
|
||||||
|
and now, so repeating it is not the verbatim redraw this guards against. */
|
||||||
|
const justOffered = new Set();
|
||||||
|
for (let i = history.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (history[i]?.role !== 'assistant') continue;
|
||||||
|
for (const chip of history[i].followUp || []) {
|
||||||
|
justOffered.add(asQuestion(chip.prompt || chip.label));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const unused = (chips) => (chips || []).filter((chip) => {
|
||||||
|
const key = asQuestion(chip.prompt || chip.label);
|
||||||
|
if (!key || asked.has(key) || justOffered.has(key) || seen.has(key)) return false;
|
||||||
|
/* A list that repeats itself within one turn is the same defect at a
|
||||||
|
smaller scale. */
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onTopic = unused(await refresh({ query: question }));
|
||||||
|
return onTopic.length ? onTopic : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stored thread, with any completed-then-reopen action stripped out.
|
* A stored thread, with any completed-then-reopen action stripped out.
|
||||||
*
|
*
|
||||||
@@ -237,7 +319,24 @@ function withoutAuthoringActions(messages = []) {
|
|||||||
* routing applies to both without either knowing it exists.
|
* routing applies to both without either knowing it exists.
|
||||||
*/
|
*/
|
||||||
export function useConversation({
|
export function useConversation({
|
||||||
contextId, facts, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
|
contextId, facts, onNavigate, onAction, onRefreshSuggestions,
|
||||||
|
/**
|
||||||
|
* How each conversation's record gets written, keyed by the flow's id.
|
||||||
|
*
|
||||||
|
* A single `onCreatePosition` prop was the last place the panel named one
|
||||||
|
* kind of record. A second conversation needed a second prop, a second branch
|
||||||
|
* at the write, and a second set of outcome renderers — three edits to answer
|
||||||
|
* "and now employee roles too". This is one entry in a map.
|
||||||
|
*/
|
||||||
|
flowWriters = {},
|
||||||
|
/**
|
||||||
|
* The page's layout session, when the surface has one.
|
||||||
|
*
|
||||||
|
* Read for the tree Owliver inspects and called to preview or keep a change.
|
||||||
|
* Absent on every page that composes no tree, and every branch that touches
|
||||||
|
* it checks first — so the panel behaves exactly as it did before on those.
|
||||||
|
*/
|
||||||
|
uiEditing = null,
|
||||||
onAssignWorkers, onScheduleInterview,
|
onAssignWorkers, onScheduleInterview,
|
||||||
/* Finishing a draft: the same two mutations the Create Position form calls.
|
/* Finishing a draft: the same two mutations the Create Position form calls.
|
||||||
Passed in rather than reached for, so this layer still writes nothing
|
Passed in rather than reached for, so this layer still writes nothing
|
||||||
@@ -245,6 +344,15 @@ export function useConversation({
|
|||||||
onUpdatePosition, onGenerateDescription,
|
onUpdatePosition, onGenerateDescription,
|
||||||
workforce = null, disabledSkills = [], customSkills = [],
|
workforce = null, disabledSkills = [], customSkills = [],
|
||||||
roles = [], skillCategories = [], courses = [], skillContext = null,
|
roles = [], skillCategories = [], courses = [], skillContext = null,
|
||||||
|
/* The clients this organization already staffs for, offered as chips on the
|
||||||
|
company question. Derived from postings the caller can already read. */
|
||||||
|
companies = [],
|
||||||
|
/* The caller's own postings, which is where role-to-certification relevance
|
||||||
|
is observed from. See `certificationsForRole`. */
|
||||||
|
postings = null,
|
||||||
|
/* The worker profiles a declared role can be recorded against, as
|
||||||
|
`{ id, name, email }`. Same rule: already-loaded, already-permitted rows. */
|
||||||
|
workers = [],
|
||||||
/**
|
/**
|
||||||
* The active agent and where the reader is.
|
* The active agent and where the reader is.
|
||||||
*
|
*
|
||||||
@@ -257,6 +365,11 @@ export function useConversation({
|
|||||||
/* The page's own name, recorded with an archived thread so History can say
|
/* The page's own name, recorded with an archived thread so History can say
|
||||||
where a conversation happened without resolving the context again. */
|
where a conversation happened without resolving the context again. */
|
||||||
pageLabel = '',
|
pageLabel = '',
|
||||||
|
/* The language the agent answers in — a tag from `language.js`, owned by the
|
||||||
|
header's selector. Read here only to put it on the request: nothing in this
|
||||||
|
hook branches on it, because translating is the model's job and not the
|
||||||
|
panel's. */
|
||||||
|
language = DEFAULT_LANGUAGE,
|
||||||
}) {
|
}) {
|
||||||
/**
|
/**
|
||||||
* One thread per agent per page.
|
* One thread per agent per page.
|
||||||
@@ -475,7 +588,10 @@ export function useConversation({
|
|||||||
intent = {
|
intent = {
|
||||||
kind: 'flow',
|
kind: 'flow',
|
||||||
skill,
|
skill,
|
||||||
...advancePositionFlow({ flow: flowRef.current, answer: text, skill, roles }),
|
...advanceFlow({
|
||||||
|
registry: flowFor(skill), flow: flowRef.current, answer: text, skill,
|
||||||
|
ctx: { roles, companies, workers, postings },
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,11 +606,28 @@ export function useConversation({
|
|||||||
question: text,
|
question: text,
|
||||||
contextId: turnContext,
|
contextId: turnContext,
|
||||||
disabledSkills: turnDisabled,
|
disabledSkills: turnDisabled,
|
||||||
customSkills, roles, skillCategories,
|
customSkills, roles, skillCategories, companies, postings,
|
||||||
courses, workforce, skillContext, positionId,
|
courses, workforce, skillContext, positionId,
|
||||||
agent: turnAgent,
|
agent: turnAgent,
|
||||||
agentCoversPage: turnCovers,
|
agentCoversPage: turnCovers,
|
||||||
agentSuggestion, owliverContext,
|
agentSuggestion, owliverContext,
|
||||||
|
ui: uiEditing
|
||||||
|
? {
|
||||||
|
available: Boolean(uiEditing.tree?.length),
|
||||||
|
tree: uiEditing.tree,
|
||||||
|
previewing: uiEditing.previewing,
|
||||||
|
role: uiEditing.role || null,
|
||||||
|
registry: uiEditing.registry || undefined,
|
||||||
|
/* Where the reader is standing. What can be added here is a
|
||||||
|
property of the page, not of the registry, and this is how
|
||||||
|
the conversation learns it. */
|
||||||
|
page: uiEditing.page || null,
|
||||||
|
/* What this session last changed, so "change it back" has an
|
||||||
|
"it". A node id and nothing else — see `focus` on the
|
||||||
|
editing provider. */
|
||||||
|
focus: uiEditing.focus || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}),
|
}),
|
||||||
/* Only while a real agent is behind the panel. With the local
|
/* Only while a real agent is behind the panel. With the local
|
||||||
simulator there is nothing better to defer TO, and deferring
|
simulator there is nothing better to defer TO, and deferring
|
||||||
@@ -503,19 +636,56 @@ export function useConversation({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A layout change, acted on before the reply says what happened.
|
||||||
|
*
|
||||||
|
* Three kinds, and the split is the safety property: a preview is shown and
|
||||||
|
* nothing is stored; an apply keeps what was already shown; an answer — a
|
||||||
|
* question back, or a refusal — changes nothing at all. Owliver never
|
||||||
|
* applies in the same turn it proposes.
|
||||||
|
*
|
||||||
|
* `propose` validates against the tree on screen and refuses rather than
|
||||||
|
* previewing something that could not be kept, so a refusal here is
|
||||||
|
* reported in the words the engine gave rather than a generic apology.
|
||||||
|
*/
|
||||||
|
if (intent.kind === 'ui-preview' && uiEditing) {
|
||||||
|
const result = uiEditing.propose(intent.op);
|
||||||
|
if (!result.ok) {
|
||||||
|
intent = {
|
||||||
|
...intent,
|
||||||
|
kind: 'ui-answer',
|
||||||
|
doc: doc(textBlock(result.problems[0]?.message || 'That change is not possible here.')),
|
||||||
|
followUp: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (intent.kind === 'ui-apply' && uiEditing) {
|
||||||
|
const result = await uiEditing.apply();
|
||||||
|
if (!result.ok) {
|
||||||
|
intent = { ...intent, doc: doc(textBlock('That change could not be saved.')) };
|
||||||
|
}
|
||||||
|
} else if (intent.kind === 'ui-discard' && uiEditing) {
|
||||||
|
uiEditing.discard();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one step that writes. It happens before the reply rather than after,
|
* The one step that writes. It happens before the reply rather than after,
|
||||||
* because the reply is the outcome — "Position created successfully" has to
|
* because the reply is the outcome — "Position created successfully" has to
|
||||||
* be true when it is said.
|
* be true when it is said.
|
||||||
*/
|
*/
|
||||||
if (intent.kind === 'flow' && intent.create) {
|
if (intent.kind === 'flow' && intent.create) {
|
||||||
|
/* The skill says which conversation this is, so the write and the wording
|
||||||
|
of its outcome both come from that registry rather than from a name
|
||||||
|
hardcoded here. */
|
||||||
|
const registry = flowFor(intent.skill);
|
||||||
let created = null;
|
let created = null;
|
||||||
/* Kept, not swallowed. The reply states the outcome, and "it did not
|
/* Kept, not swallowed. The reply states the outcome, and "it did not
|
||||||
work" is a worse outcome to state than the reason it did not: a
|
work" is a worse outcome to state than the reason it did not: a
|
||||||
required field, a refused role, or an API that is not running. */
|
required field, a refused role, or an API that is not running. */
|
||||||
let failure = null;
|
let failure = null;
|
||||||
try {
|
try {
|
||||||
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
|
created = await flowWriters[registry.id]?.(
|
||||||
|
intent.create.draft, intent.skill, intent.create.status
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
created = null;
|
created = null;
|
||||||
failure = error;
|
failure = error;
|
||||||
@@ -551,19 +721,16 @@ export function useConversation({
|
|||||||
intent = {
|
intent = {
|
||||||
...intent,
|
...intent,
|
||||||
flow: null,
|
flow: null,
|
||||||
doc: positionCreatedReply(created),
|
doc: registry.outcome.created(created),
|
||||||
followUp: [...createdFollowUp(created), ...refreshed],
|
followUp: [...registry.outcome.followUp(created), ...refreshed],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
/* Keep the answers: the summary is still there to try again from. */
|
/* Keep the answers: the summary is still there to try again from. */
|
||||||
intent = {
|
intent = {
|
||||||
...intent,
|
...intent,
|
||||||
flow: { ...intent.flow, stage: 'review' },
|
flow: { ...intent.flow, stage: 'review' },
|
||||||
doc: positionFailedReply(failure?.message),
|
doc: registry.outcome.failed(failure?.message),
|
||||||
followUp: [
|
followUp: registry.outcome.retryChips,
|
||||||
{ label: 'Create position', prompt: 'Create position' },
|
|
||||||
{ label: 'Change details', prompt: 'Change details' },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -755,6 +922,9 @@ export function useConversation({
|
|||||||
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
|
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
|
||||||
owliverContext,
|
owliverContext,
|
||||||
agentVersion: pinnedVersionRef.current,
|
agentVersion: pinnedVersionRef.current,
|
||||||
|
/* The reader's choice, applied from the next answer onward. Answers
|
||||||
|
already on screen are left as they were written. */
|
||||||
|
language,
|
||||||
})) {
|
})) {
|
||||||
if (controller.signal.aborted) break;
|
if (controller.signal.aborted) break;
|
||||||
latest = snapshot;
|
latest = snapshot;
|
||||||
@@ -765,9 +935,53 @@ export function useConversation({
|
|||||||
// half-written answer loses what they were already reading. Stopping
|
// half-written answer loses what they were already reading. Stopping
|
||||||
// before the first block, though, should leave no empty turn behind.
|
// before the first block, though, should leave no empty turn behind.
|
||||||
if (latest.length) {
|
if (latest.length) {
|
||||||
|
/**
|
||||||
|
* What to ask NEXT — which is not the same as what is worth asking.
|
||||||
|
*
|
||||||
|
* Every other path in this file ends its turn with `followUp`; the
|
||||||
|
* agent path was the one that did not, so an agent answer was the only
|
||||||
|
* kind that left the chip row empty.
|
||||||
|
*
|
||||||
|
* The first attempt at fixing that asked for the page's untyped
|
||||||
|
* suggestions, and they are ranked by signal rather than by the
|
||||||
|
* conversation — so a page with six intents offered its top three, and
|
||||||
|
* offered the same three after every answer, including the one that had
|
||||||
|
* just been asked. Three standing highlights repeated verbatim are not
|
||||||
|
* follow-ups; they are the landing screen redrawn under a reply.
|
||||||
|
*
|
||||||
|
* So the question is passed to the server as the query, which ranks the
|
||||||
|
* same catalogue against what was actually asked, and anything this
|
||||||
|
* thread has already asked or already offered is removed. What is left
|
||||||
|
* is what this conversation has not covered yet — which is what a
|
||||||
|
* follow-up is. When nothing is left, nothing is shown: a panel with
|
||||||
|
* nothing new to suggest should say so by being quiet, not by repeating
|
||||||
|
* itself.
|
||||||
|
*
|
||||||
|
* A stopped run is offered nothing. The reader interrupted the answer,
|
||||||
|
* so the next step it implies has not been established.
|
||||||
|
*/
|
||||||
|
let followUp;
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
try {
|
||||||
|
followUp = await nextSteps({
|
||||||
|
question: text,
|
||||||
|
history: messagesRef.current,
|
||||||
|
refresh: onRefreshSuggestions,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* The answer arrived; failing to fetch what to ask next is not a
|
||||||
|
reason to withhold it. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const next = [
|
const next = [
|
||||||
...messagesRef.current,
|
...messagesRef.current,
|
||||||
{ role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
blocks: latest,
|
||||||
|
stopped: controller.signal.aborted || undefined,
|
||||||
|
...(followUp ? { followUp } : null),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
messagesRef.current = next;
|
messagesRef.current = next;
|
||||||
persist(next);
|
persist(next);
|
||||||
@@ -785,13 +999,18 @@ export function useConversation({
|
|||||||
setPending(null);
|
setPending(null);
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
|
}, [contextId, facts, persist, onNavigate, onAction, flowWriters, onRefreshSuggestions,
|
||||||
onUpdatePosition,
|
onUpdatePosition,
|
||||||
onGenerateDescription, onAssignWorkers,
|
onGenerateDescription, onAssignWorkers,
|
||||||
onScheduleInterview,
|
onScheduleInterview,
|
||||||
workforce, setFlow, disabledSkills,
|
workforce, setFlow, disabledSkills,
|
||||||
customSkills, roles, skillCategories, courses, skillContext,
|
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
|
||||||
agent, agentCoversPage, agentSuggestion, owliverContext]);
|
agent, agentCoversPage, agentSuggestion, owliverContext, language,
|
||||||
|
/* The layout session changes as a page's composition and the account's
|
||||||
|
skills resolve, and a stale one means the tree Owliver inspects is the
|
||||||
|
empty one from the first render — so a layout request falls through to
|
||||||
|
the model and comes back as "I don't cover that". */
|
||||||
|
uiEditing]);
|
||||||
|
|
||||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||||
|
|
||||||
@@ -836,6 +1055,9 @@ export function useConversation({
|
|||||||
owliverContext,
|
owliverContext,
|
||||||
confirmation: block.token,
|
confirmation: block.token,
|
||||||
agentVersion: pinnedVersionRef.current,
|
agentVersion: pinnedVersionRef.current,
|
||||||
|
/* The reader's choice, applied from the next answer onward. Answers
|
||||||
|
already on screen are left as they were written. */
|
||||||
|
language,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})) {
|
})) {
|
||||||
if (controller.signal.aborted) break;
|
if (controller.signal.aborted) break;
|
||||||
@@ -855,7 +1077,7 @@ export function useConversation({
|
|||||||
setPending(null);
|
setPending(null);
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
}, [contextId, facts, agent, owliverContext, persist]);
|
}, [contextId, facts, agent, owliverContext, language, persist]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* States something in the thread without a question having been asked.
|
* States something in the thread without a question having been asked.
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from '@/components/ds/props';
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
@@ -104,7 +105,7 @@ function getDeptColor(index, avgScore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function DepartmentPerformance({ items = [], className = '' }) {
|
export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||||
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
|
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
|
||||||
const [activeDept, setActiveDept] = useState(null);
|
const [activeDept, setActiveDept] = useState(null);
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from '@/components/ds/props';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BarChart } from '@mui/x-charts';
|
import { BarChart } from '@mui/x-charts';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
@@ -19,7 +20,7 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
|||||||
* component derives no figures of its own.
|
* component derives no figures of its own.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }) {
|
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }: DsProps) {
|
||||||
if (!stages.length) return null;
|
if (!stages.length) return null;
|
||||||
|
|
||||||
const total = stages[0]?.count || 1;
|
const total = stages[0]?.count || 1;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from '@/components/ds/props';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LineChart } from '@mui/x-charts';
|
import { LineChart } from '@mui/x-charts';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -15,7 +16,7 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
|||||||
* `points: [{ label, hires, cumulative }]` — supplied by the page.
|
* `points: [{ label, hires, cumulative }]` — supplied by the page.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function HiringTrendChart({ points = [], emptyState, className = '' }) {
|
export function HiringTrendChart({ points = [], emptyState, className = '' }: DsProps) {
|
||||||
const enough = points.length >= 2;
|
const enough = points.length >= 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Avatar } from '@/components/ds/Avatar';
|
import { Avatar } from '@/components/ds/Avatar';
|
||||||
@@ -30,7 +31,7 @@ export function ActivityCard({
|
|||||||
variant = 'row',
|
variant = 'row',
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const iconTones = {
|
const iconTones = {
|
||||||
soft: 'bg-krow-blue-tint text-krow-blue',
|
soft: 'bg-krow-blue-tint text-krow-blue',
|
||||||
brand: 'bg-krow-blue text-white',
|
brand: 'bg-krow-blue text-white',
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
|
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
|
||||||
import { cva } from 'class-variance-authority';
|
import { cva } from 'class-variance-authority';
|
||||||
@@ -40,7 +41,7 @@ const ICON_COLORS = {
|
|||||||
* that persists is noise.
|
* that persists is noise.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) {
|
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }: DsProps) {
|
||||||
const Icon = icon || ICONS[tone];
|
const Icon = icon || ICONS[tone];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cva } from 'class-variance-authority';
|
import { cva } from 'class-variance-authority';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -41,8 +42,7 @@ export function initialsFrom(name = '') {
|
|||||||
* first-class state rather than a fallback afterthought. A failed image load
|
* first-class state rather than a fallback afterthought. A failed image load
|
||||||
* degrades to initials instead of a broken-image icon.
|
* degrades to initials instead of a broken-image icon.
|
||||||
*/
|
*/
|
||||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
export const Avatar: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
|
||||||
export const Avatar = React.forwardRef(
|
|
||||||
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
|
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
|
||||||
const [failed, setFailed] = React.useState(false);
|
const [failed, setFailed] = React.useState(false);
|
||||||
const showImage = src && !failed;
|
const showImage = src && !failed;
|
||||||
@@ -91,7 +91,7 @@ Avatar.displayName = 'Avatar';
|
|||||||
* people in a tight row (applicants on a position, endorsers on a skill).
|
* people in a tight row (applicants on a position, endorsers on a skill).
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) {
|
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }: DsProps) {
|
||||||
const visible = people.slice(0, max);
|
const visible = people.slice(0, max);
|
||||||
const overflow = people.length - visible.length;
|
const overflow = people.length - visible.length;
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ResponsiveContainer } from 'recharts';
|
import { ResponsiveContainer } from 'recharts';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -38,7 +39,7 @@ export const AXIS_PROPS = {
|
|||||||
* white box that ignores the token set.
|
* white box that ignores the token set.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) {
|
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }: DsProps) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,7 +72,7 @@ export function ChartTooltip({ active, payload, label, valueFormatter, labelForm
|
|||||||
* A legend that reads as part of the card rather than as chart furniture.
|
* A legend that reads as part of the card rather than as chart furniture.
|
||||||
* Items: `{ label, color, value? }`.
|
* Items: `{ label, color, value? }`.
|
||||||
*/
|
*/
|
||||||
export function ChartLegend({ items = [], className }) {
|
export function ChartLegend({ items = [], className }: DsProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
|
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
@@ -118,7 +119,7 @@ export function ChartContainer({
|
|||||||
raw = false,
|
raw = false,
|
||||||
footer,
|
footer,
|
||||||
className,
|
className,
|
||||||
}) {
|
}: DsProps) {
|
||||||
if (loading) return <SkeletonChart className={className} height={height} />;
|
if (loading) return <SkeletonChart className={className} height={height} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
|
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -60,7 +61,7 @@ export function DataTable({
|
|||||||
className,
|
className,
|
||||||
rowClassName,
|
rowClassName,
|
||||||
stickyHeader = false,
|
stickyHeader = false,
|
||||||
}) {
|
}: DsProps) {
|
||||||
/* ── Sorting ─────────────────────────────────────────────────────────── */
|
/* ── Sorting ─────────────────────────────────────────────────────────── */
|
||||||
/* Row height follows the account's density preference. Only the vertical
|
/* Row height follows the account's density preference. Only the vertical
|
||||||
padding changes — same columns, same type, same behaviour. */
|
padding changes — same columns, same type, same behaviour. */
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
@@ -41,7 +42,7 @@ export function Drawer({
|
|||||||
className,
|
className,
|
||||||
/** Hides the header for a fully custom panel (the mobile nav does this). */
|
/** Hides the header for a fully custom panel (the mobile nav does this). */
|
||||||
bare = false,
|
bare = false,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const isVertical = side === 'top' || side === 'bottom';
|
const isVertical = side === 'top' || side === 'bottom';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -25,7 +26,7 @@ export function EmptyState({
|
|||||||
variant = 'empty',
|
variant = 'empty',
|
||||||
size = 'default',
|
size = 'default',
|
||||||
className,
|
className,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const renderAction = (spec, buttonVariant) => {
|
const renderAction = (spec, buttonVariant) => {
|
||||||
if (!spec) return null;
|
if (!spec) return null;
|
||||||
if (React.isValidElement(spec)) return spec;
|
if (React.isValidElement(spec)) return spec;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ export function Field({
|
|||||||
inline = false,
|
inline = false,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const reactId = React.useId();
|
const reactId = React.useId();
|
||||||
const id = htmlFor || reactId;
|
const id = htmlFor || reactId;
|
||||||
const hintId = hint ? `${id}-hint` : undefined;
|
const hintId = hint ? `${id}-hint` : undefined;
|
||||||
@@ -27,11 +28,15 @@ export function Field({
|
|||||||
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
|
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
|
||||||
|
|
||||||
// Only clone when the child is a single element that can accept the wiring.
|
// Only clone when the child is a single element that can accept the wiring.
|
||||||
|
/* `isValidElement` narrows to `ReactElement<unknown>`, whose `props` is
|
||||||
|
`unknown` — so reading `children.props.id` to preserve a caller's own id
|
||||||
|
does not compile. The cast says what the guard has already established and
|
||||||
|
what the runtime relies on: this is an element with props. */
|
||||||
const control = React.isValidElement(children)
|
const control = React.isValidElement(children)
|
||||||
? React.cloneElement(children, {
|
? React.cloneElement(children as React.ReactElement<Record<string, any>>, {
|
||||||
id: children.props.id || id,
|
id: (children.props as Record<string, any>).id || id,
|
||||||
'aria-describedby': children.props['aria-describedby'] || describedBy,
|
'aria-describedby': (children.props as Record<string, any>)['aria-describedby'] || describedBy,
|
||||||
'aria-invalid': error ? true : children.props['aria-invalid'],
|
'aria-invalid': error ? true : (children.props as Record<string, any>)['aria-invalid'],
|
||||||
})
|
})
|
||||||
: children;
|
: children;
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { SlidersHorizontal, X } from 'lucide-react';
|
import { SlidersHorizontal, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -37,7 +38,7 @@ export function FilterBar({
|
|||||||
/** Value that means "no filter" for select-type filters. */
|
/** Value that means "no filter" for select-type filters. */
|
||||||
allValue = 'all',
|
allValue = 'all',
|
||||||
className = '',
|
className = '',
|
||||||
}) {
|
}: DsProps) {
|
||||||
const [expanded, setExpanded] = React.useState(false);
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
|
|
||||||
const isActive = (filter) => {
|
const isActive = (filter) => {
|
||||||
@@ -9,8 +9,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
|||||||
* `label`: an icon-only control with no accessible name is a bug, so the API
|
* `label`: an icon-only control with no accessible name is a bug, so the API
|
||||||
* makes it impossible to omit. The label doubles as the tooltip.
|
* makes it impossible to omit. The label doubles as the tooltip.
|
||||||
*/
|
*/
|
||||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
export const IconButton: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
|
||||||
export const IconButton = React.forwardRef(
|
|
||||||
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
|
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
|
||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
xs: 'icon-xs',
|
xs: 'icon-xs',
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react';
|
import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -58,7 +59,7 @@ export function InsightRow({
|
|||||||
onClick = null,
|
onClick = null,
|
||||||
actionLabel = 'Review',
|
actionLabel = 'Review',
|
||||||
className = '',
|
className = '',
|
||||||
}) {
|
}: DsProps) {
|
||||||
const s = SEVERITY[severity] || SEVERITY.info;
|
const s = SEVERITY[severity] || SEVERITY.info;
|
||||||
const Comp = onClick ? 'button' : 'div';
|
const Comp = onClick ? 'button' : 'div';
|
||||||
|
|
||||||
@@ -107,6 +108,6 @@ export function InsightRow({
|
|||||||
|
|
||||||
/** The list container — dividers between rows, nothing else. */
|
/** The list container — dividers between rows, nothing else. */
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function InsightList({ children, className = '' }) {
|
export function InsightList({ children, className = '' }: DsProps) {
|
||||||
return <div className={cn('divide-y divide-border', className)}>{children}</div>;
|
return <div className={cn('divide-y divide-border', className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
||||||
@@ -32,7 +33,7 @@ export function KpiCard({
|
|||||||
loading = false,
|
loading = false,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const t = TONES[tone] || TONES.brand;
|
const t = TONES[tone] || TONES.brand;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -112,7 +113,7 @@ export function MetricCard({
|
|||||||
loading = false,
|
loading = false,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const t = TONES[tone] || TONES.neutral;
|
const t = TONES[tone] || TONES.neutral;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ const GAPS = { sm: 'gap-3', default: 'gap-4', lg: 'gap-6' };
|
|||||||
* chosen to avoid orphan cards (6 → 2/3/6 rather than 1/3/6).
|
* chosen to avoid orphan cards (6 → 2/3/6 rather than 1/3/6).
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function Grid({ cols = 3, gap = 'default', className, children, ...props }) {
|
export function Grid({ cols = 3, gap = 'default', className, children, ...props }: DsProps) {
|
||||||
const columns = {
|
const columns = {
|
||||||
1: 'grid-cols-1',
|
1: 'grid-cols-1',
|
||||||
2: 'grid-cols-1 sm:grid-cols-2',
|
2: 'grid-cols-1 sm:grid-cols-2',
|
||||||
@@ -38,7 +39,7 @@ export function Grid({ cols = 3, gap = 'default', className, children, ...props
|
|||||||
|
|
||||||
/** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
|
/** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function Stack({ space = 'default', className, children, ...props }) {
|
export function Stack({ space = 'default', className, children, ...props }: DsProps) {
|
||||||
const spacing = {
|
const spacing = {
|
||||||
xs: 'space-y-2',
|
xs: 'space-y-2',
|
||||||
sm: 'space-y-3',
|
sm: 'space-y-3',
|
||||||
@@ -59,7 +60,7 @@ export function Stack({ space = 'default', className, children, ...props }) {
|
|||||||
* with its content at consistent spacing.
|
* with its content at consistent spacing.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) {
|
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }: DsProps) {
|
||||||
return (
|
return (
|
||||||
<section className={cn('space-y-3', className)}>
|
<section className={cn('space-y-3', className)}>
|
||||||
{title && (
|
{title && (
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -6,7 +7,7 @@ import { cn } from '@/lib/utils';
|
|||||||
* Spinner — the only spinner in the system.
|
* Spinner — the only spinner in the system.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function Spinner({ size = 'default', className = '', label = 'Loading' }) {
|
export function Spinner({ size = 'default', className = '', label = 'Loading' }: DsProps) {
|
||||||
const sizes = { xs: 'w-3.5 h-3.5', sm: 'w-4 h-4', default: 'w-6 h-6', lg: 'w-8 h-8' };
|
const sizes = { xs: 'w-3.5 h-3.5', sm: 'w-4 h-4', default: 'w-6 h-6', lg: 'w-8 h-8' };
|
||||||
return (
|
return (
|
||||||
<Loader2
|
<Loader2
|
||||||
@@ -25,7 +26,7 @@ export function Spinner({ size = 'default', className = '', label = 'Loading' })
|
|||||||
* final layout prevents the content jump a spinner always causes.
|
* final layout prevents the content jump a spinner always causes.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function LoadingState({ message = '', size = 'default', className = '' }) {
|
export function LoadingState({ message = '', size = 'default', className = '' }: DsProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -45,7 +46,7 @@ export function LoadingState({ message = '', size = 'default', className = '' })
|
|||||||
* card headers, or beside a control that is refreshing.
|
* card headers, or beside a control that is refreshing.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function InlineLoading({ message = 'Loading…', className = '' }) {
|
export function InlineLoading({ message = 'Loading…', className = '' }: DsProps) {
|
||||||
return (
|
return (
|
||||||
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
|
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
@@ -59,7 +60,7 @@ export function InlineLoading({ message = 'Loading…', className = '' }) {
|
|||||||
* height stable so the page does not jump. Used by DataTable when refetching.
|
* height stable so the page does not jump. Used by DataTable when refetching.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function LoadingOverlay({ show = false, message = '', className = '' }) {
|
export function LoadingOverlay({ show = false, message = '', className = '' }: DsProps) {
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user