Compare commits
42 Commits
chore/untr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 1a0dc7e5f1 | |||
| 1d35358dd6 |
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/
|
||||
main.jsx entry — mounts <App/>, imports index.css
|
||||
App.jsx providers + the entire route table
|
||||
main.tsx entry — mounts <App/>, imports index.css
|
||||
App.tsx providers + the entire route table
|
||||
index.css design tokens (HSL CSS variables) + utility layers
|
||||
|
||||
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
|
||||
aiEngine.js local, deterministic InvokeLLM
|
||||
seed.js demo dataset
|
||||
attendanceSeed.js shift/attendance dataset
|
||||
aiEngine.ts local, deterministic InvokeLLM
|
||||
seed.ts demo dataset
|
||||
attendanceSeed.ts shift/attendance dataset
|
||||
|
||||
lib/ domain logic and data hooks
|
||||
krowHooks.js React Query hooks over the entity store
|
||||
krowAi.js AI workflows
|
||||
krowScore.js score engine
|
||||
AuthContext.jsx auth provider
|
||||
krowHooks.ts React Query hooks over the entity store
|
||||
krowAi.ts AI workflows
|
||||
krowScore.ts score engine
|
||||
AuthContext.tsx auth provider
|
||||
admin/ admin session, permissions, position insights
|
||||
skills/ the Owliver skill system (registry, resolver, flows)
|
||||
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/owliver/new`, `owliver/:id` | Owliver skill editor |
|
||||
| `/admin/workspace/skill-development` | Skill Development |
|
||||
| `*` | `lib/PageNotFound.jsx` |
|
||||
| `*` | `lib/PageNotFound.tsx` |
|
||||
|
||||
Legacy top-level paths (`/overview`, `/positions`, `/candidates`, `/hired`, `/talent-pool`,
|
||||
`/university`, `/analytics`, `/activity`, `/tracking`) all `Navigate` into their `/admin/*`
|
||||
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"`.
|
||||
|
||||
> **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`, …)
|
||||
> 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.
|
||||
|
||||
## 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:
|
||||
|
||||
| Contract | Demo implementation |
|
||||
| --- | --- |
|
||||
| `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.auth.*` | seeded employer/admin user, always signed in |
|
||||
|
||||
Because the seam did not move, everything above it — `lib/krowHooks.js`, `lib/krowAi.js`,
|
||||
`lib/krowScore.js`, every page and component — is unchanged from the reference implementation.
|
||||
Because the seam did not move, everything above it — `lib/krowHooks.ts`, `lib/krowAi.ts`,
|
||||
`lib/krowScore.ts`, every page and component — is unchanged from the reference implementation.
|
||||
|
||||
## The AI engine
|
||||
|
||||
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`,
|
||||
`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 |
|
||||
| --- | --- | --- |
|
||||
| `lib/skills/registry.js` | `import.meta.glob('/src/skills/**/*.md')` | 18 Owliver skills, 5 workforce training skills |
|
||||
| `lib/agents/registry.js` | `import.meta.glob('/src/agents/**/*.md')` | 9 agent definitions |
|
||||
| `lib/skills/registry.ts` | `import.meta.glob('/src/skills/**/*.md')` | 19 Owliver skills, 5 workforce training skills |
|
||||
| `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
|
||||
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
|
||||
|
||||
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
|
||||
`src/api/attendanceSeed.js`.
|
||||
`src/api/attendanceSeed.ts`.
|
||||
|
||||
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 |
|
||||
|
||||
`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
|
||||
`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
|
||||
`App.jsx`.
|
||||
`App.tsx`.
|
||||
|
||||
## 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 lint` | ESLint — currently clean |
|
||||
| `npm run lint:fix` | ESLint with `--fix` |
|
||||
| `npm test` | `scripts/skill-check.mjs` — 835 assertions over the skill and agent systems |
|
||||
| `npm run typecheck` | `tsc -p ./jsconfig.json` with `checkJs` |
|
||||
| `npm test` | `scripts/skill-check.mjs` — 1693 assertions over the skill and agent systems |
|
||||
| `npm run typecheck` | `tsc -p ./tsconfig.json` — 0 errors across 321 TypeScript files |
|
||||
|
||||
### 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.
|
||||
|
||||
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/layouts/AdminLayout.jsx` and a handful of agent files. Move any of them and update
|
||||
`src/lib/skills/`, `src/lib/agents/`, `src/components/ai-assistant/`, `src/App.tsx`,
|
||||
`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.
|
||||
|
||||
### 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.
|
||||
- `npm run typecheck` — 59 errors, all pre-existing JSDoc/inference gaps in `lib/krowAi.js`,
|
||||
`lib/skills/*`, `lib/positionModel.js` and a few components.
|
||||
This section used to list two known failures — 834/835 on the suite, and 59 type
|
||||
errors across `lib/krowAi`, `lib/skills/*` and `lib/positionModel`. Both are
|
||||
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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"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 pluginReactHooks from "eslint-plugin-react-hooks";
|
||||
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 [
|
||||
{
|
||||
files: [
|
||||
"src/components/**/*.{js,mjs,cjs,jsx}",
|
||||
"src/pages/**/*.{js,mjs,cjs,jsx}",
|
||||
"src/layouts/**/*.{js,mjs,cjs,jsx}",
|
||||
"src/hooks/**/*.{js,mjs,cjs,jsx}",
|
||||
],
|
||||
ignores: ["src/lib/**/*", "src/components/ui/**/*"],
|
||||
files: filesWith("js,mjs,cjs,jsx"),
|
||||
ignores,
|
||||
...pluginJs.configs.recommended,
|
||||
...pluginReact.configs.flat.recommended,
|
||||
languageOptions: {
|
||||
@@ -20,42 +72,50 @@ export default [
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
settings,
|
||||
plugins,
|
||||
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: {
|
||||
react: pluginReact,
|
||||
"react-hooks": pluginReactHooks,
|
||||
"unused-imports": pluginUnusedImports,
|
||||
},
|
||||
settings,
|
||||
plugins: { ...plugins, "@typescript-eslint": tseslint.plugin },
|
||||
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",
|
||||
...rules,
|
||||
/* TypeScript resolves identifiers itself and reports the ones it cannot,
|
||||
with better messages and without ESLint's browser/node globals list
|
||||
needing to be right. Leaving the core rule on would report every `type`
|
||||
and `interface` name as undefined. */
|
||||
"no-undef": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</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:fix": "eslint . --fix",
|
||||
"test": "node scripts/skill-check.mjs",
|
||||
"seed:fixture": "node scripts/seed-fixture.mjs --write",
|
||||
"seed:check": "node scripts/seed-fixture.mjs",
|
||||
"typecheck": "tsc -p ./jsconfig.json",
|
||||
"preview": "vite preview"
|
||||
"seed:fixture": "node scripts/seed-fixture.mjs --write",
|
||||
"seed:check": "node scripts/seed-fixture.mjs",
|
||||
"typecheck": "tsc -p ./tsconfig.json",
|
||||
"preview": "vite preview",
|
||||
"test:browser": "node scripts/browser-check.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astryxdesign/core": "^0.3.0",
|
||||
@@ -61,8 +62,10 @@
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "^8.5.3",
|
||||
"puppeteer-core": "^23.11.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.70.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.
|
||||
|
||||
## 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
|
||||
|
||||
`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.
|
||||
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.
|
||||
|
||||
## 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",
|
||||
"candidate-analysis",
|
||||
"candidate-search",
|
||||
"create-employee-role",
|
||||
"create-position",
|
||||
"customer-service-training",
|
||||
"executive-summary",
|
||||
@@ -161,6 +162,7 @@
|
||||
"pageKey": "positions",
|
||||
"route": "/admin/positions",
|
||||
"skills": [
|
||||
"create-employee-role",
|
||||
"create-position",
|
||||
"hiring-activity-assistant",
|
||||
"staffing-risk"
|
||||
@@ -707,6 +709,7 @@
|
||||
"pageKey": "talent-pool",
|
||||
"route": "/admin/talent-pool",
|
||||
"skills": [
|
||||
"create-employee-role",
|
||||
"talent-pool-analysis"
|
||||
],
|
||||
"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 { createServer } from 'vite';
|
||||
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
|
||||
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
@@ -26,6 +27,11 @@ const server = await createServer({
|
||||
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);
|
||||
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 { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||
|
||||
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) {
|
||||
const { ShiftRecord, ...entities } = seedModule.seedData;
|
||||
/* First key, so it is the first thing anyone opening the file reads. The Go
|
||||
loader unmarshals into a struct of demoUser + entities and ignores the
|
||||
rest, so this costs nothing on the reading side. */
|
||||
return { _generated: GENERATED_NOTE, demoUser: seedModule.DEMO_USER, entities };
|
||||
/* `_generated` first, so it is the first thing anyone opening the file reads.
|
||||
|
||||
`users` is the channel the seeder actually reads — `internal/seeder`
|
||||
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. */
|
||||
@@ -51,6 +73,9 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const server = await createServer({
|
||||
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);
|
||||
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 Owliver from '@/pages/Owliver';
|
||||
import EmployeeDashboard from '@/pages/EmployeeDashboard';
|
||||
import OpportunityDetail from '@/pages/OpportunityDetail';
|
||||
import DesignSystem from '@/pages/DesignSystem';
|
||||
|
||||
/* 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 AdminHiredHistory from '@/pages/admin/HiredHistory';
|
||||
import AdminTalentPool from '@/pages/admin/TalentPool';
|
||||
import AdminTalentProfile from '@/pages/admin/TalentProfile';
|
||||
import AdminAnalytics from '@/pages/admin/Analytics';
|
||||
import AdminActivity from '@/pages/admin/Activity';
|
||||
import AdminProfile from '@/pages/admin/Profile';
|
||||
@@ -98,8 +100,20 @@ const AuthenticatedApp = () => {
|
||||
screening dimensions, which a 480px column cannot show without
|
||||
hiding most of it. */}
|
||||
<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="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/:id" element={<CourseDetail />} />
|
||||
<Route path="analytics" element={<AdminAnalytics />} />
|
||||
@@ -131,6 +145,12 @@ const AuthenticatedApp = () => {
|
||||
</Route>
|
||||
|
||||
{/* 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="/positions" element={<Navigate to="/admin/positions" replace />} />
|
||||
{/* `/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.
|
||||
icon: briefcase
|
||||
status: published
|
||||
version: 1
|
||||
version: 2
|
||||
reasoning: balanced
|
||||
trigger: Use on Positions, for open roles, applicant flow, and specifying a new role.
|
||||
pages:
|
||||
@@ -12,6 +12,7 @@ pages:
|
||||
- create-position
|
||||
skills:
|
||||
- create-position
|
||||
- create-employee-role
|
||||
- hiring-activity-assistant
|
||||
- staffing-risk
|
||||
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.
|
||||
icon: layers
|
||||
status: published
|
||||
version: 1
|
||||
version: 2
|
||||
reasoning: balanced
|
||||
trigger: Use on Talent Pool, for supply, availability and readiness of known workers.
|
||||
pages:
|
||||
- talent-pool
|
||||
skills:
|
||||
- talent-pool-analysis
|
||||
- create-employee-role
|
||||
starters:
|
||||
- label: Who is available?
|
||||
prompt: Who is available in the talent pool?
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* 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. */
|
||||
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 ──────────────────────────────────────────────── */
|
||||
|
||||
/** 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'));
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
function numField(prompt, label) {
|
||||
function numField(prompt: string, label: string): number {
|
||||
const raw = field(prompt, label);
|
||||
const match = raw.match(/-?\d+(\.\d+)?/);
|
||||
return match ? Number(match[0]) : 0;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return [];
|
||||
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** 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*"""/);
|
||||
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 ────────────────────────────────────────────── */
|
||||
|
||||
function screenCandidate(prompt) {
|
||||
function screenCandidate(prompt: string) {
|
||||
// Weights are stated inline: "experience 25%, english 20%, …"
|
||||
const weightOf = (name) => {
|
||||
const weightOf = (name: string) => {
|
||||
const match = prompt.match(new RegExp(`${name} (\\d+)%`));
|
||||
return match ? Number(match[1]) : 20;
|
||||
};
|
||||
@@ -184,7 +184,7 @@ function screenCandidate(prompt) {
|
||||
|
||||
/* ── 2. Job description generator ──────────────────────────────────────── */
|
||||
|
||||
const RESPONSIBILITY_LIBRARY = {
|
||||
const RESPONSIBILITY_LIBRARY: Record<string, string[]> = {
|
||||
Bartender: [
|
||||
'Set up and break down a full service bar',
|
||||
'Pour to spec at event pace without sacrificing presentation',
|
||||
@@ -236,7 +236,7 @@ const DEFAULT_RESPONSIBILITIES = [
|
||||
'Complete close-out duties before leaving',
|
||||
];
|
||||
|
||||
function generateJobDescription(prompt) {
|
||||
function generateJobDescription(prompt: string) {
|
||||
const title = field(prompt, 'Role Title');
|
||||
const category = field(prompt, 'Role Category');
|
||||
const minYears = numField(prompt, 'Min Experience');
|
||||
@@ -301,7 +301,7 @@ const CERT_KEYWORDS = [
|
||||
['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'],
|
||||
];
|
||||
|
||||
function buildResume(prompt) {
|
||||
function buildResume(prompt: string) {
|
||||
const text = quotedBlock(prompt);
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
@@ -343,14 +343,14 @@ function buildResume(prompt) {
|
||||
|
||||
const INTERVIEW_QUESTIONS = {
|
||||
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?`,
|
||||
() => `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?`,
|
||||
() => `Last one — what does your schedule realistically look like, and what do you want to be doing a year from now?`,
|
||||
],
|
||||
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?`,
|
||||
() => `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?`,
|
||||
@@ -358,7 +358,7 @@ const INTERVIEW_QUESTIONS = {
|
||||
],
|
||||
};
|
||||
|
||||
function interviewQuestion(prompt) {
|
||||
function interviewQuestion(prompt: string) {
|
||||
const lang = isSpanish(prompt) ? 'es' : 'en';
|
||||
const numberMatch = prompt.match(/Ask question (\d+) of 5/);
|
||||
const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
||||
@@ -369,7 +369,7 @@ function interviewQuestion(prompt) {
|
||||
|
||||
const OWLIVER_QUESTIONS = {
|
||||
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.`,
|
||||
() => `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?`,
|
||||
@@ -377,7 +377,7 @@ const OWLIVER_QUESTIONS = {
|
||||
() => `That's everything I needed. Anything you want to add before I put this together?`,
|
||||
],
|
||||
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.`,
|
||||
() => `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?`,
|
||||
@@ -386,7 +386,7 @@ const OWLIVER_QUESTIONS = {
|
||||
],
|
||||
};
|
||||
|
||||
function owliverQuestion(prompt) {
|
||||
function owliverQuestion(prompt: string) {
|
||||
const lang = isSpanish(prompt) ? 'es' : 'en';
|
||||
const numberMatch = prompt.match(/you are on question (\d+)/);
|
||||
const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
||||
@@ -398,7 +398,7 @@ function owliverQuestion(prompt) {
|
||||
/* ── 5. Interview evaluation ───────────────────────────────────────────── */
|
||||
|
||||
/** 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 answers = transcript
|
||||
.split('\n')
|
||||
@@ -412,7 +412,7 @@ function readTranscript(prompt) {
|
||||
return { answers, words, avgWords, specifics };
|
||||
}
|
||||
|
||||
function evaluateInterview(prompt) {
|
||||
function evaluateInterview(prompt: string) {
|
||||
const { answers, avgWords, specifics } = readTranscript(prompt);
|
||||
const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/);
|
||||
const fast = fastMatch ? Number(fastMatch[1]) : 0;
|
||||
@@ -489,9 +489,14 @@ function evaluateInterview(prompt) {
|
||||
|
||||
/* ── 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/);
|
||||
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 {
|
||||
pool = block ? JSON.parse(block[1]) : [];
|
||||
} catch {
|
||||
@@ -513,9 +518,9 @@ function matchTalent(prompt) {
|
||||
? Math.min(20, ((p.experience_years || 0) / minYears) * 20)
|
||||
: 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
|
||||
? (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);
|
||||
|
||||
const scoreFit = ((p.krow_score || 0) / 100) * 20;
|
||||
@@ -547,7 +552,11 @@ function matchTalent(prompt) {
|
||||
/* ── 7. Owliver profile builder ────────────────────────────────────────── */
|
||||
|
||||
/** 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'],
|
||||
[/executive chef/, 'Executive Chef'],
|
||||
[/sous chef/, 'Sous Chef'],
|
||||
@@ -563,7 +572,8 @@ const ROLE_TITLES = [
|
||||
[/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'],
|
||||
[/weekday|monday|tuesday|wednesday|thursday|friday|semana/i, 'Weekdays'],
|
||||
[/evening|night|noche|tarde/i, 'Evenings'],
|
||||
@@ -572,7 +582,7 @@ const AVAILABILITY_TOKENS = [
|
||||
[/on.?call|flexible/i, 'On-Call'],
|
||||
];
|
||||
|
||||
function buildCareerDna(prompt) {
|
||||
function buildCareerDna(prompt: string) {
|
||||
const transcript = quotedBlock(prompt);
|
||||
const lower = transcript.toLowerCase();
|
||||
const { answers, avgWords, specifics } = (() => {
|
||||
@@ -696,7 +706,7 @@ function buildCareerDna(prompt) {
|
||||
|
||||
/* ── 8. Proving Ground evaluator ───────────────────────────────────────── */
|
||||
|
||||
function evaluateChallenge(prompt) {
|
||||
function evaluateChallenge(prompt: string) {
|
||||
const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/);
|
||||
const criteria = criteriaMatch
|
||||
? 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';
|
||||
|
||||
// Spread the overall score across the rubric so criteria are not identical.
|
||||
const rubric = {};
|
||||
const rubric: Record<string, number> = {};
|
||||
criteria.forEach((criterion, i) => {
|
||||
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
|
||||
* 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 an expert hiring copywriter/m, generateJobDescription, 1400],
|
||||
[/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],
|
||||
];
|
||||
|
||||
/** 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));
|
||||
|
||||
if (!route) {
|
||||
@@ -800,7 +842,7 @@ export async function invokeLLM({ prompt = '', response_json_schema: schema } =
|
||||
}
|
||||
|
||||
/** Drop-in replacement for `integrations.Core.UploadFile`. */
|
||||
export async function uploadFile({ file } = {}) {
|
||||
export async function uploadFile({ file }: { file?: File } = {}) {
|
||||
await think(500);
|
||||
if (!file) return { file_url: '' };
|
||||
// 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
|
||||
* 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());
|
||||
d.setDate(d.getDate() - n);
|
||||
d.setHours(hour, minute, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
const round1 = (n) => Math.round(n * 10) / 10;
|
||||
const round2 = (n) => Math.round(n * 100) / 100;
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
@@ -105,10 +105,21 @@ const ROSTER = [
|
||||
* Returning a plain record keeps every rule visible in one place instead of
|
||||
* 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
|
||||
the nights events actually overrun. */
|
||||
staff_marco: (i, weekday) => ({
|
||||
staff_marco: (i: number, weekday?: number) => ({
|
||||
status: i === 14 ? 'late' : 'present',
|
||||
minutesLate: i === 14 ? 9 : 0,
|
||||
/* 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
|
||||
* specific enough to act on.
|
||||
*/
|
||||
staff_marcus: (i) => {
|
||||
staff_marcus: (i: number) => {
|
||||
if (i === 2 || i === 7) {
|
||||
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
|
||||
* 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 busy = weekday === 4 || weekday === 5 || weekday === 6;
|
||||
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. */
|
||||
function shiftOffsets(weekdays, anchor) {
|
||||
const offsets = [];
|
||||
function shiftOffsets(weekdays: number[], anchor: Date) {
|
||||
const offsets: number[] = [];
|
||||
for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) {
|
||||
const day = daysAgo(offset, 9, 0, anchor).getDay();
|
||||
if (weekdays.includes(day)) offsets.push(offset);
|
||||
@@ -163,16 +174,16 @@ function shiftOffsets(weekdays, anchor) {
|
||||
return offsets;
|
||||
}
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
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
|
||||
* the point of this collection; a caller passes one only to hold the window
|
||||
* still — see buildShiftsAt.
|
||||
*/
|
||||
function buildShifts(anchor = new Date()) {
|
||||
const records = [];
|
||||
function buildShifts(anchor: Date = new Date()) {
|
||||
const records: any[] = [];
|
||||
|
||||
for (const worker of ROSTER) {
|
||||
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
|
||||
* different windows.
|
||||
*/
|
||||
export function buildShiftsAt(anchor) {
|
||||
export function buildShiftsAt(anchor: Date) {
|
||||
return buildShifts(anchor);
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
*/
|
||||
|
||||
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
|
||||
import type { PreferencesUpdateResult } from '@/types/user';
|
||||
import { invokeLLM, uploadFile } from './aiEngine';
|
||||
import { DEMO_USER } from './demoUser';
|
||||
|
||||
@@ -29,6 +30,11 @@ const ENTITY_NAMES = [
|
||||
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
||||
'Course', 'Badge', 'LearningPath', 'Certification', 'RoleCategory',
|
||||
'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"
|
||||
into workforce allocation: without it a position knows its demand and its
|
||||
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
|
||||
* 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) => {
|
||||
currentUser = user;
|
||||
authenticated = true;
|
||||
cacheUser();
|
||||
return user;
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((): null => {
|
||||
authenticated = false;
|
||||
return null;
|
||||
});
|
||||
@@ -199,7 +209,11 @@ const auth = {
|
||||
* by design: telling the two apart would say whether an address 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', {
|
||||
body: { email, password, remember_me: Boolean(rememberMe) },
|
||||
});
|
||||
@@ -210,7 +224,7 @@ const auth = {
|
||||
return { ...user };
|
||||
},
|
||||
|
||||
async updateMe(patch) {
|
||||
async updateMe(patch: any) {
|
||||
const user = await request('PATCH', '/me', { body: patch });
|
||||
currentUser = user;
|
||||
cacheUser();
|
||||
@@ -242,7 +256,7 @@ const auth = {
|
||||
* question the shape exists to answer is now answered by whether this
|
||||
* function resolved at all.
|
||||
*/
|
||||
async updatePreferences(patch) {
|
||||
async updatePreferences(patch: any): Promise<PreferencesUpdateResult> {
|
||||
const preferences = await request('PATCH', '/me/preferences', { body: patch });
|
||||
currentUser = { ...currentUser, preferences };
|
||||
cacheUser();
|
||||
@@ -269,7 +283,7 @@ const auth = {
|
||||
* 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.
|
||||
*/
|
||||
async logout(redirectTo = '/admin/login') {
|
||||
async logout(redirectTo: string = '/admin/login') {
|
||||
try {
|
||||
await request('POST', '/auth/logout');
|
||||
} catch {
|
||||
@@ -315,7 +329,7 @@ const workflows = {
|
||||
* 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.
|
||||
*/
|
||||
async hire(applicationId, body = {}) {
|
||||
async hire(applicationId: string, body: any = {}) {
|
||||
return request('POST', `/job-applications/${encodeURIComponent(applicationId)}/hire`, {
|
||||
body,
|
||||
});
|
||||
@@ -331,7 +345,7 @@ const workflows = {
|
||||
* inside the same transaction; a worker taken straight from the talent pool
|
||||
* 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`, {
|
||||
body: { workers },
|
||||
});
|
||||
@@ -363,7 +377,7 @@ const workflows = {
|
||||
*/
|
||||
const owliver = {
|
||||
/** @param {any} request */
|
||||
async suggestions({ page, query = '' } = {}) {
|
||||
async suggestions({ page, query = '' }: { page?: string; query?: string } = {}) {
|
||||
if (!page) return [];
|
||||
const typed = String(query || '').trim();
|
||||
const data = await request('GET', '/owliver/suggestions', {
|
||||
@@ -383,7 +397,7 @@ const integrations = {
|
||||
};
|
||||
|
||||
const analytics = {
|
||||
track({ eventName, properties } = {}) {
|
||||
track({ eventName, properties }: { eventName?: string; properties?: any } = {}) {
|
||||
if (import.meta.env.DEV) {
|
||||
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.
|
||||
*
|
||||
@@ -22,7 +24,7 @@
|
||||
* the server has never stored still has to resolve to something rather than to
|
||||
* `undefined`.
|
||||
*/
|
||||
export const DEMO_USER = {
|
||||
export const DEMO_USER: User = {
|
||||
id: 'user_demo',
|
||||
full_name: 'Alex Rivera',
|
||||
email: 'demo@krow.app',
|
||||
@@ -37,3 +39,29 @@ export const DEMO_USER = {
|
||||
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.
|
||||
*/
|
||||
|
||||
import type { ApiErrorResponse, KrowApiError } from '@/types/api';
|
||||
import type { EntityName, EntityResourcePath } from '@/types/entities';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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',
|
||||
JobApplication: 'job-applications',
|
||||
AIInterview: 'ai-interviews',
|
||||
Staff: 'staff',
|
||||
WorkerProfile: 'worker-profiles',
|
||||
EmployeeRole: 'employee-roles',
|
||||
Course: 'courses',
|
||||
Badge: 'badges',
|
||||
LearningPath: 'learning-paths',
|
||||
@@ -127,7 +137,7 @@ const RESOURCE_PATHS = {
|
||||
* `got === undefined` against real values and matched nothing, and sending the
|
||||
* 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();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined) continue;
|
||||
@@ -152,9 +162,14 @@ function queryString(params) {
|
||||
* ride along as properties — new information a local store never had, and
|
||||
* 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 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.status = status;
|
||||
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
|
||||
* 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) : ''}`;
|
||||
|
||||
let response;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
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 ` +
|
||||
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
|
||||
`(${method} ${path})`
|
||||
);
|
||||
) as KrowApiError;
|
||||
error.name = 'KrowApiError';
|
||||
error.status = 0;
|
||||
error.code = 'unreachable';
|
||||
@@ -220,7 +239,7 @@ async function request(method, path, { query, body } = {}) {
|
||||
if (!response.ok) throw apiError(response.status, payload);
|
||||
|
||||
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.status = response.status;
|
||||
error.code = 'internal';
|
||||
@@ -241,7 +260,7 @@ export { request };
|
||||
* 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".
|
||||
*/
|
||||
export function isUnauthenticated(error) {
|
||||
export function isUnauthenticated(error: any) {
|
||||
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
|
||||
* them rather than passing their own.
|
||||
*/
|
||||
export function createEntity(name) {
|
||||
const path = RESOURCE_PATHS[name];
|
||||
export function createEntity(name: string) {
|
||||
/* `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}`);
|
||||
const base = `/${path}`;
|
||||
|
||||
return {
|
||||
entityName: name,
|
||||
entityName: name as EntityName,
|
||||
|
||||
/**
|
||||
* `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 } });
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
async get(id: string) {
|
||||
return request('GET', `${base}/${encodeURIComponent(id)}`);
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
async create(data: any) {
|
||||
return request('POST', base, { body: data });
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
async update(id: string, data: any) {
|
||||
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
async delete(id: string) {
|
||||
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
|
||||
* are already written.
|
||||
*/
|
||||
async bulkCreate(records = []) {
|
||||
const created = [];
|
||||
async bulkCreate(records: any[] = []) {
|
||||
const created: any[] = [];
|
||||
for (const record of records) created.push(await this.create(record));
|
||||
return created;
|
||||
},
|
||||
@@ -9,9 +9,9 @@
|
||||
*/
|
||||
|
||||
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 ────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -347,7 +347,7 @@ const JOB_POSTINGS = [
|
||||
|
||||
/* ── Applications ──────────────────────────────────────────────────────── */
|
||||
|
||||
const breakdown = (over) => ({
|
||||
const breakdown = (over?: Record<string, number>) => ({
|
||||
experience: 0, english: 0, reliability: 0, certifications: 0, availability: 0,
|
||||
personality: 0, culture_fit: 0, communication_style: 0, attendance_expectations: 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. */
|
||||
const unscreened = (over) => ({
|
||||
const unscreened = (over?: Record<string, any>) => ({
|
||||
status: 'applied',
|
||||
ai_score: 0,
|
||||
certifications: [],
|
||||
availability: [],
|
||||
skills: [],
|
||||
companies_worked: [],
|
||||
certifications: [] as string[],
|
||||
availability: [] as string[],
|
||||
skills: [] as string[],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
created_date: iso('2026-08-04'),
|
||||
updated_date: iso('2026-08-04'),
|
||||
@@ -428,7 +428,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['ServSafe', 'CPR / First Aid'],
|
||||
availability: ['Weekdays', 'Weekends', 'Evenings'],
|
||||
skills: ['Menu design', 'Brigade leadership', 'Food costing', 'Plated dinners', 'Offsite catering'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.9,
|
||||
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.',
|
||||
@@ -480,7 +480,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['Guard Card', 'CPR / First Aid'],
|
||||
availability: ['Weekends', 'Evenings', 'Overnight'],
|
||||
skills: ['Crowd management', 'De-escalation', 'Incident reporting', 'Access control'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.8,
|
||||
professional_summary:
|
||||
'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'],
|
||||
availability: ['Weekends', 'Evenings'],
|
||||
skills: ['Access control', 'Incident reporting'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
professional_summary: 'Venue security officer transitioning into private event work.',
|
||||
job_posting_id: 'job_security',
|
||||
@@ -562,7 +562,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['RBS Alcohol Server', 'TIPS Certified'],
|
||||
availability: ['Weekdays', 'Evenings'],
|
||||
skills: ['Batch cocktails', 'Craft cocktails', 'Bar setup', 'Inventory control'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.7,
|
||||
professional_summary:
|
||||
'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'],
|
||||
availability: ['Weekends', 'Evenings'],
|
||||
skills: ['Synchronized service', 'Tray service', 'Wine service', 'Guest relations'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.6,
|
||||
professional_summary:
|
||||
'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'],
|
||||
availability: ['Weekends'],
|
||||
skills: ['Banquet service', 'Tray service', 'Table resets'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.4,
|
||||
professional_summary: 'Banquet server with three years of seated dinner service.',
|
||||
selfie_url: 'https://i.pravatar.cc/240?img=44',
|
||||
@@ -673,7 +673,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: [],
|
||||
availability: ['Weekends', 'Evenings'],
|
||||
skills: ['Table service'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
professional_summary: 'Restaurant server moving into event work.',
|
||||
job_posting_id: 'job_server_fine',
|
||||
@@ -695,7 +695,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['TIPS Certified'],
|
||||
availability: ['Weekends', 'Evenings'],
|
||||
skills: ['Classic cocktails', 'Bar setup', 'Speed pouring'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.2,
|
||||
professional_summary: 'Event bartender with three years of high-volume weekend service.',
|
||||
job_posting_id: 'job_bartender',
|
||||
@@ -728,7 +728,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['TIPS Certified'],
|
||||
availability: ['Evenings'],
|
||||
skills: ['Bar setup', 'Wine service'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 4.0,
|
||||
professional_summary: 'Barback stepping up to bartending at private events.',
|
||||
job_posting_id: 'job_bartender',
|
||||
@@ -761,7 +761,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: [],
|
||||
availability: ['Weekends'],
|
||||
skills: [],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
professional_summary: 'Looking for a first hospitality role.',
|
||||
job_posting_id: 'job_bartender',
|
||||
@@ -795,7 +795,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['Forklift Operator'],
|
||||
availability: ['Weekdays', 'Early mornings'],
|
||||
skills: ['Order picking', 'Scanner operation', 'Pallet staging'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 3.9,
|
||||
professional_summary: 'Warehouse picker with forklift certification.',
|
||||
job_posting_id: 'job_picker',
|
||||
@@ -827,7 +827,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: [],
|
||||
availability: ['Weekdays'],
|
||||
skills: ['Order picking'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
professional_summary: 'Fulfillment associate seeking steady warehouse shifts.',
|
||||
job_posting_id: 'job_picker',
|
||||
@@ -854,7 +854,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['Food Handler Card'],
|
||||
availability: ['Weekdays', 'Weekends'],
|
||||
skills: ['Banquet leadership', 'BEO reading', 'Floor coordination'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
professional_summary: 'Banquet server with lead-shift experience in hotel events.',
|
||||
job_posting_id: 'job_banquet',
|
||||
@@ -874,7 +874,7 @@ const JOB_APPLICATIONS = [
|
||||
certifications: ['Food Handler Card'],
|
||||
availability: ['Weekdays', 'Weekends', 'Evenings'],
|
||||
skills: ['Floor leadership', 'Client relations', 'Event reporting'],
|
||||
companies_worked: [],
|
||||
companies_worked: [] as string[],
|
||||
client_rating: 0,
|
||||
professional_summary: 'Hotel banquet captain with six years directing event floors.',
|
||||
job_posting_id: 'job_banquet',
|
||||
@@ -900,7 +900,7 @@ const JOB_APPLICATIONS = [
|
||||
|
||||
/* ── 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: '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') },
|
||||
@@ -919,7 +919,7 @@ const AI_INTERVIEWS = [
|
||||
verdict: 'maybe',
|
||||
hire_recommendation: 'Worth a trial shift on a coursed dinner before committing to the season.',
|
||||
integrity_score: 100,
|
||||
ai_flags: [],
|
||||
ai_flags: [] as string[],
|
||||
category_scores: {
|
||||
communication: 78, confidence: 71, experience_relevance: 74, culture_fit: 76,
|
||||
problem_solving: 69, personality: 77, communication_style: 76,
|
||||
@@ -947,11 +947,11 @@ const AI_INTERVIEWS = [
|
||||
verdict: 'maybe',
|
||||
hire_recommendation: '',
|
||||
integrity_score: 100,
|
||||
ai_flags: [],
|
||||
ai_flags: [] as string[],
|
||||
category_scores: {},
|
||||
strengths: [],
|
||||
concerns: [],
|
||||
best_fit_roles: [],
|
||||
strengths: [] as string[],
|
||||
concerns: [] as string[],
|
||||
best_fit_roles: [] as string[],
|
||||
summary: '',
|
||||
reasoning: '',
|
||||
created_date: iso('2026-08-05'),
|
||||
@@ -967,11 +967,11 @@ const AI_INTERVIEWS = [
|
||||
verdict: 'maybe',
|
||||
hire_recommendation: '',
|
||||
integrity_score: 100,
|
||||
ai_flags: [],
|
||||
ai_flags: [] as string[],
|
||||
category_scores: {},
|
||||
strengths: [],
|
||||
concerns: [],
|
||||
best_fit_roles: [],
|
||||
strengths: [] as string[],
|
||||
concerns: [] as string[],
|
||||
best_fit_roles: [] as string[],
|
||||
summary: '',
|
||||
reasoning: '',
|
||||
created_date: iso('2026-08-05'),
|
||||
@@ -987,11 +987,11 @@ const AI_INTERVIEWS = [
|
||||
verdict: 'maybe',
|
||||
hire_recommendation: '',
|
||||
integrity_score: 100,
|
||||
ai_flags: [],
|
||||
ai_flags: [] as string[],
|
||||
category_scores: {},
|
||||
strengths: [],
|
||||
concerns: [],
|
||||
best_fit_roles: [],
|
||||
strengths: [] as string[],
|
||||
concerns: [] as string[],
|
||||
best_fit_roles: [] as string[],
|
||||
summary: '',
|
||||
reasoning: '',
|
||||
created_date: iso('2026-08-03'),
|
||||
@@ -1015,7 +1015,7 @@ const STAFF = [
|
||||
ai_score: 92,
|
||||
status: 'onboarding',
|
||||
client_rating: 0,
|
||||
endorsed_skills: [],
|
||||
endorsed_skills: [] as string[],
|
||||
created_date: iso('2026-07-25'),
|
||||
},
|
||||
{
|
||||
@@ -1031,7 +1031,7 @@ const STAFF = [
|
||||
ai_score: 94,
|
||||
status: 'onboarding',
|
||||
client_rating: 0,
|
||||
endorsed_skills: [],
|
||||
endorsed_skills: [] as string[],
|
||||
created_date: iso('2026-07-25'),
|
||||
},
|
||||
{
|
||||
@@ -1047,7 +1047,7 @@ const STAFF = [
|
||||
ai_score: 97,
|
||||
status: 'onboarding',
|
||||
client_rating: 0,
|
||||
endorsed_skills: [],
|
||||
endorsed_skills: [] as string[],
|
||||
created_date: iso('2026-07-25'),
|
||||
},
|
||||
];
|
||||
@@ -1085,7 +1085,7 @@ const COURSES = [
|
||||
{ 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: [
|
||||
{
|
||||
question: 'A guest flags an allergy mid-service. What happens first?',
|
||||
@@ -1127,7 +1127,7 @@ const COURSES = [
|
||||
{ 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: [
|
||||
{
|
||||
question: 'A guest shows clear signs of intoxication. What do you do?',
|
||||
@@ -1151,7 +1151,7 @@ const COURSES = [
|
||||
proof_skill: 'Hazard Identification',
|
||||
skill_id: 'food_safety',
|
||||
target_level: 'beginner',
|
||||
required_level: null,
|
||||
required_level: null as string | null,
|
||||
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
|
||||
verification_criteria: ['Identifies temperature violations', 'Spots cross-contamination risk', 'Notes obstruction hazards'],
|
||||
challenge: {
|
||||
@@ -1164,7 +1164,7 @@ const COURSES = [
|
||||
{ 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: [
|
||||
{
|
||||
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'] },
|
||||
quiz: [],
|
||||
quiz: [] as unknown[],
|
||||
pass_score: 80,
|
||||
status: 'active',
|
||||
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
|
||||
* 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', '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']],
|
||||
@@ -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']],
|
||||
];
|
||||
|
||||
const SKILL_CATEGORY = {
|
||||
const SKILL_CATEGORY: Record<string, string> = {
|
||||
server: 'Service',
|
||||
bartending: 'Bar',
|
||||
leadership: 'Leadership',
|
||||
@@ -1294,20 +1304,20 @@ const SKILL_CATEGORY = {
|
||||
const LEVEL_ORDER = ['beginner', 'intermediate', 'advanced', 'expert'];
|
||||
|
||||
/** 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. */
|
||||
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.`,
|
||||
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.`,
|
||||
};
|
||||
|
||||
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(
|
||||
([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,
|
||||
rubric: (Array.isArray(criteria) ? criteria : []).map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
|
||||
},
|
||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
||||
quiz: [],
|
||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
|
||||
quiz: [] as unknown[],
|
||||
pass_score: 70,
|
||||
status: 'active',
|
||||
created_date: iso('2026-06-20'),
|
||||
@@ -1344,7 +1354,7 @@ const TRAINING_MODULES = MODULE_TABLE.map(
|
||||
const COURSE_LIBRARY = [...COURSES, ...TRAINING_MODULES];
|
||||
|
||||
/** 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.
|
||||
@@ -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
|
||||
* 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) => ({
|
||||
course_id: id,
|
||||
title: COURSE_LIBRARY.find((c) => c.id === id)?.title || id,
|
||||
@@ -1525,7 +1535,7 @@ const WORKER_PROFILES = [
|
||||
earned_badges: [
|
||||
{ name: 'Service Fundamentals', level: 'bronze', course_id: 'course_service_basics', earned_date: iso('2026-07-28') },
|
||||
],
|
||||
capabilities: [],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 6,
|
||||
attendance_score: 92,
|
||||
performance_score: 0,
|
||||
@@ -1595,8 +1605,8 @@ const WORKER_PROFILES = [
|
||||
'2026-08-02',
|
||||
89
|
||||
),
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 38,
|
||||
attendance_score: 96,
|
||||
performance_score: 88,
|
||||
@@ -1643,8 +1653,8 @@ const WORKER_PROFILES = [
|
||||
'2026-07-30',
|
||||
85
|
||||
),
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 21,
|
||||
attendance_score: 93,
|
||||
performance_score: 81,
|
||||
@@ -1691,8 +1701,8 @@ const WORKER_PROFILES = [
|
||||
'2026-08-05',
|
||||
76
|
||||
),
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 4,
|
||||
attendance_score: 100,
|
||||
performance_score: 0,
|
||||
@@ -1735,8 +1745,8 @@ const WORKER_PROFILES = [
|
||||
},
|
||||
xp: 0,
|
||||
completed_courses: [],
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 0,
|
||||
attendance_score: 100,
|
||||
performance_score: 0,
|
||||
@@ -1779,8 +1789,8 @@ const WORKER_PROFILES = [
|
||||
},
|
||||
xp: 0,
|
||||
completed_courses: [],
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 0,
|
||||
attendance_score: 100,
|
||||
performance_score: 0,
|
||||
@@ -1823,8 +1833,8 @@ const WORKER_PROFILES = [
|
||||
},
|
||||
xp: 0,
|
||||
completed_courses: [],
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 0,
|
||||
attendance_score: 100,
|
||||
performance_score: 0,
|
||||
@@ -1867,8 +1877,8 @@ const WORKER_PROFILES = [
|
||||
},
|
||||
xp: 0,
|
||||
completed_courses: [],
|
||||
earned_badges: [],
|
||||
capabilities: [],
|
||||
earned_badges: [] as unknown[],
|
||||
capabilities: [] as unknown[],
|
||||
shifts_completed: 0,
|
||||
attendance_score: 100,
|
||||
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.
|
||||
* The fixtures here are for the test scripts, and they still read
|
||||
* `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 ───────────────────────────────────────────────────────────
|
||||
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_name: 'Marco Rivera',
|
||||
starts_at: iso('2026-07-01'),
|
||||
ends_at: null,
|
||||
ends_at: null as string | null,
|
||||
status: 'active',
|
||||
source: 'seed',
|
||||
match_score: 92,
|
||||
@@ -2041,5 +2055,5 @@ export const seedData = {
|
||||
Assignment: ASSIGNMENTS,
|
||||
ShiftRecord: SHIFT_RECORDS,
|
||||
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
|
||||
* 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 { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
||||
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
|
||||
* Admin header only needs one 56px bar.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
|
||||
interface AdminPageProps {
|
||||
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 (
|
||||
<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">
|
||||
@@ -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
|
||||
* four or five sections, loud labels compete with the data they introduce.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function SectionTitle({ id, title, meta, action, className }) {
|
||||
interface SectionTitleProps {
|
||||
id?: any;
|
||||
title?: string;
|
||||
meta?: any;
|
||||
action?: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionTitle({ id, title, meta, action, className }: SectionTitleProps) {
|
||||
return (
|
||||
<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
|
||||
@@ -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
|
||||
* control geometry.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }) {
|
||||
interface ToolbarProps {
|
||||
search?: any;
|
||||
filters?: any;
|
||||
actions?: any;
|
||||
meta?: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }: ToolbarProps) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import {
|
||||
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
|
||||
} 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. */
|
||||
/** @type {[RegExp, React.ComponentType<any>][]} */
|
||||
const GLYPHS = [
|
||||
/* `[pattern, icon]` pairs. Without the tuple the element widens to the union of
|
||||
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],
|
||||
[/bartend|bar|mixolog/i, Wine],
|
||||
[/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' },
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
|
||||
interface RoleGlyphProps {
|
||||
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 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
|
||||
* 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 (
|
||||
<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">
|
||||
@@ -64,8 +64,7 @@ export function Workspace({ rail, children, className }) {
|
||||
* height without measuring it in JavaScript. Content stays mounted, which is
|
||||
* what keeps a section addressable by the rail while it is closed.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Collapse({ open, children, className }) {
|
||||
export function Collapse({ open, children, className }: any) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -100,8 +99,7 @@ export function Collapse({ open, children, className }) {
|
||||
* Provides clear section navigation and leaf anchor shortcuts across the
|
||||
* configuration workspace.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Rail({ sections, activeId, onSelect }) {
|
||||
export function Rail({ sections, activeId, onSelect }: any) {
|
||||
const activeSection = sections.find((s) => s.id === activeId);
|
||||
|
||||
return (
|
||||
@@ -181,11 +179,10 @@ export function Rail({ sections, activeId, onSelect }) {
|
||||
/**
|
||||
* One section of the document.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function DocSection({
|
||||
id, icon: Icon, title, description, meta, open, onToggle, emphasis = false, last = false,
|
||||
children,
|
||||
}) {
|
||||
}: any) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
@@ -257,8 +254,7 @@ export function DocSection({
|
||||
}
|
||||
|
||||
/** A labelled field in the document body. */
|
||||
/** @param {any} props */
|
||||
export function DocField({ id, label, hint, children, className }) {
|
||||
export function DocField({ id, label, hint, children, className }: any) {
|
||||
const reactId = React.useId();
|
||||
const controlId = id ? `${id}-control` : reactId;
|
||||
|
||||
@@ -270,7 +266,7 @@ export function DocField({ id, label, hint, children, className }) {
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{React.isValidElement(children)
|
||||
{React.isValidElement<any>(children)
|
||||
? React.cloneElement(children, { id: children.props.id || controlId })
|
||||
: children}
|
||||
{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
|
||||
* exactly the level of the thing it names.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function GroupHead({ id, icon: Icon, title, count, action, className }) {
|
||||
export function GroupHead({ id, icon: Icon, title, count, action, className }: any) {
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
* which is what they are.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }) {
|
||||
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }: any) {
|
||||
return (
|
||||
<li
|
||||
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
|
||||
* space gets a chevron and opens underneath.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function SettingRow({
|
||||
id, icon: Icon, title, value, description, control, children, open, onToggle, last = false,
|
||||
}) {
|
||||
}: any) {
|
||||
const expandable = Boolean(children);
|
||||
|
||||
const body = (
|
||||
@@ -412,8 +405,7 @@ export function SettingRow({
|
||||
}
|
||||
|
||||
/** A page an agent covers, or one it could. */
|
||||
/** @param {any} props */
|
||||
export function ScopeChip({ active, children, ...props }) {
|
||||
export function ScopeChip({ active, children, ...props }: any) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -443,8 +435,7 @@ export function ScopeChip({ active, children, ...props }) {
|
||||
/* ── Identity ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The icons an agent may wear, as a picker. */
|
||||
/** @param {any} props */
|
||||
export function IconPicker({ icons, value, onChange }) {
|
||||
export function IconPicker({ icons, value, onChange }: any) {
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{icons.map((icon) => {
|
||||
@@ -486,8 +477,7 @@ export function IconPicker({ icons, value, onChange }) {
|
||||
* same three things the switcher renders — icon, name, description — and
|
||||
* nothing it does not.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AgentPreview({ name, description, icon }) {
|
||||
export function AgentPreview({ name, description, icon }: any) {
|
||||
const Icon = agentIconFor(icon);
|
||||
|
||||
return (
|
||||
@@ -94,7 +94,6 @@ function useActiveSection(ids) {
|
||||
return [active, setActive];
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentConfigure({
|
||||
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
|
||||
/* 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
|
||||
rather than fetched here so this component stays a form over `fields`. */
|
||||
toolCatalogue = [],
|
||||
}) {
|
||||
}: any) {
|
||||
/* 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
|
||||
is for focus, not for the initial reading. */
|
||||
@@ -804,7 +803,7 @@ export function AgentConfigure({
|
||||
icon={Layers}
|
||||
title="Subagents"
|
||||
value={fields.subagents.length ? `${fields.subagents.length} configured` : 'None'}
|
||||
description="Other agents whose skills this one may also use. Most need none."
|
||||
description="Other agents this one may hand a question to and report back from. Most need none."
|
||||
open={setting === 'behavior-subagents'}
|
||||
onToggle={() => toggleSetting('behavior-subagents')}
|
||||
last
|
||||
@@ -849,8 +848,9 @@ export function AgentConfigure({
|
||||
</Select>
|
||||
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
A subagent's skills are still bounded by the current page — borrowing them
|
||||
cannot reach data this page does not hold.
|
||||
A subagent runs as you, with the same access you have and out of the same
|
||||
budget as the question that reached it — so delegating cannot read anything
|
||||
you could not read yourself, and cannot buy more time by asking again.
|
||||
</p>
|
||||
</div>
|
||||
</SettingRow>
|
||||
@@ -22,8 +22,7 @@ import { cn } from '@/lib/utils';
|
||||
* Real-time usage analytics computed directly from the local telemetry log.
|
||||
*/
|
||||
|
||||
/** @param {any} props */
|
||||
function MetricCard({ icon: Icon, label, value, detail, highlight }) {
|
||||
function MetricCard({ icon: Icon, label, value, detail, highlight }: any) {
|
||||
return (
|
||||
<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">
|
||||
@@ -45,8 +44,7 @@ function MetricCard({ icon: Icon, label, value, detail, highlight }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentInsightsPanel({ agentId, agentName }) {
|
||||
export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||
const preferences = usePreferences();
|
||||
|
||||
const [records] = React.useState(() => readHistory());
|
||||
@@ -46,8 +46,7 @@ const CLASSIFICATION_COPY = {
|
||||
combined: 'Requires both structured records and knowledge retrieval.',
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
|
||||
export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }: any) {
|
||||
const [contextId, setContextId] = React.useState(
|
||||
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|
||||
|| CONTEXT_OPTIONS[0].contextId
|
||||
@@ -79,6 +78,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
|
||||
|
||||
return {
|
||||
covers,
|
||||
disabled,
|
||||
pageOnly,
|
||||
scoped,
|
||||
matched,
|
||||
@@ -152,6 +152,33 @@ export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
|
||||
className="pl-9 w-full bg-surface"
|
||||
/>
|
||||
</div>
|
||||
{/* Everything else on this tab is a STATIC read of where a
|
||||
question would route. Useful, and not the same as asking. The
|
||||
tab is called Test, so a reader reasonably expects to be able
|
||||
to run the thing — this is that, through the existing Owliver
|
||||
panel and the draft's own scope, so the answer comes from the
|
||||
agent being edited rather than the published one. */}
|
||||
{onRunLive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRunLive({
|
||||
question,
|
||||
scope: { contextId, disabledSkills: result.disabled, agent: draftAgent },
|
||||
capability: null,
|
||||
trace: { agentName: fields.name || 'This agent', surface: contextId },
|
||||
})}
|
||||
disabled={!question.trim() || !result.covers}
|
||||
title={result.covers
|
||||
? 'Ask this for real, in the Owliver panel'
|
||||
: 'This agent does not cover the selected page'}
|
||||
className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-border
|
||||
bg-surface px-3 py-1.5 text-caption font-medium text-ink-2
|
||||
hover:bg-surface-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Play className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
Ask Owliver for real
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,6 @@ import { SkillDetails } from './SkillDetails';
|
||||
const OWLIVER = 'owliver';
|
||||
const BOARD = 'board';
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentSkillWorkspace({
|
||||
fields,
|
||||
customSkills = [],
|
||||
@@ -55,7 +54,7 @@ export function AgentSkillWorkspace({
|
||||
busy = false,
|
||||
pendingId = null,
|
||||
dirty = false,
|
||||
}) {
|
||||
}: any) {
|
||||
const [kind, setKind] = React.useState(OWLIVER);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [boardQuery, setBoardQuery] = React.useState('');
|
||||
@@ -394,6 +393,10 @@ export function AgentSkillWorkspace({
|
||||
busy={busy}
|
||||
query={boardQuery}
|
||||
onQueryChange={setBoardQuery}
|
||||
/* Ownership, through the one handler that writes `agent.skills` —
|
||||
the same one the Owliver catalog attaches with. */
|
||||
attachedIds={attachedIds}
|
||||
onAttach={onToggleSkill}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -174,7 +174,6 @@ function StaticLeaf({ icon: Icon, label, detail = null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentTree({
|
||||
agentName,
|
||||
agentIcon,
|
||||
@@ -191,7 +190,7 @@ export function AgentTree({
|
||||
onToggleBranch,
|
||||
busy = false,
|
||||
className,
|
||||
}) {
|
||||
}: any) {
|
||||
const Glyph = agentIconFor(agentIcon);
|
||||
|
||||
return (
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { cn } from '@/lib/utils';
|
||||
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
|
||||
* 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
|
||||
* Board on it, and the reason is worth stating because it is the one thing here
|
||||
* that could quietly become a lie:
|
||||
* ever again.
|
||||
*
|
||||
* **A Board skill is not carried by an agent.** `SkillSurface` renders a
|
||||
* section from the page it names and the account's `disabledSkills`; it does not
|
||||
* read `agent.skills` and has no agent in scope. Writing a Board skill id into
|
||||
* `agent.skills` would therefore record an assignment nothing honours — a card
|
||||
* that says "Added" and a product that behaves identically either way.
|
||||
* **Two controls, because there are two different questions.** This card used to
|
||||
* offer only the workspace switch, and said so at length: a Board skill was not
|
||||
* carried by an agent, because `SkillSurface` read the page and the account's
|
||||
* `disabledSkills` and never looked at `agent.skills`. That was true, and it is
|
||||
* 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
|
||||
* *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
|
||||
* is labelled as workspace-wide, because it is.
|
||||
* So both questions are asked here, and neither is dressed up as the other:
|
||||
*
|
||||
* - **Active** is the workspace switch. Off means off for everyone, on every
|
||||
* 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. */
|
||||
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
|
||||
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = false, onAttach = null }) {
|
||||
const switchId = `board-${entry.id}`;
|
||||
|
||||
return (
|
||||
@@ -110,6 +116,31 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
|
||||
)}
|
||||
</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">
|
||||
<span className="text-caption text-ink-4">
|
||||
{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({
|
||||
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
|
||||
}) {
|
||||
attachedIds = null, onAttach = null,
|
||||
}: any) {
|
||||
const searchId = React.useId();
|
||||
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
|
||||
|
||||
@@ -147,9 +178,11 @@ export function BoardSkillList({
|
||||
|
||||
return (
|
||||
<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">
|
||||
They draw sections on KROW pages and are switched on for the whole workspace —
|
||||
so a change here affects every agent answering on that page, not just this one.
|
||||
<Alert tone="info" title="Two switches, two different questions">
|
||||
<strong>Active</strong> is workspace-wide: off means off on every page, for every
|
||||
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>
|
||||
|
||||
{total > 0 && (
|
||||
@@ -192,6 +225,8 @@ export function BoardSkillList({
|
||||
onToggle={onToggle}
|
||||
onSurfaces={onSurfaces}
|
||||
busy={busy}
|
||||
attached={Boolean(attachedIds?.has(entry.id))}
|
||||
onAttach={onAttach}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -25,9 +25,9 @@ import { glyphForGroup } from './glyphs';
|
||||
*/
|
||||
|
||||
/** @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,
|
||||
}) => {
|
||||
}: any) => {
|
||||
const Glyph = glyphForGroup(entry.group);
|
||||
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.
|
||||
*/
|
||||
|
||||
/** @param {any} props */
|
||||
function CategoryFilter({ groups, value, onChange, total }) {
|
||||
function CategoryFilter({ groups, value, onChange, total }: any) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
@@ -60,7 +59,6 @@ function CategoryFilter({ groups, value, onChange, total }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function SkillCatalog({
|
||||
entries,
|
||||
groups,
|
||||
@@ -76,7 +74,7 @@ export function SkillCatalog({
|
||||
testableIds,
|
||||
busy = false,
|
||||
pendingId = null,
|
||||
}) {
|
||||
}: any) {
|
||||
const searchId = React.useId();
|
||||
|
||||
return (
|
||||
@@ -82,11 +82,10 @@ function TestState({ test, entryId }) {
|
||||
* button needs the same `surface` this renders, and two copies of that would be
|
||||
* two answers to "what am I about to enable".
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function CapabilityBrief({
|
||||
entry, attached, surface, onSurfaceChange, question, onQuestionChange,
|
||||
canTest, target, onTest, test, busy = false, showTest = true,
|
||||
}) {
|
||||
}: any) {
|
||||
const { shapes, described } = entry.capabilities;
|
||||
|
||||
return (
|
||||
@@ -252,11 +251,10 @@ export function CapabilityBrief({
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function SkillDetails({
|
||||
entry, open, onOpenChange, isEnabledOn, onToggle, busy = false,
|
||||
fields = null, onRunTest = null, test = null,
|
||||
}) {
|
||||
}: any) {
|
||||
/**
|
||||
* The surface this would run on.
|
||||
*
|
||||
@@ -45,8 +45,7 @@ const OPTIONS = [
|
||||
export const supportedSurfaces = (type) =>
|
||||
OPTIONS.filter((o) => o.supported(type)).map((o) => o.id);
|
||||
|
||||
/** @param {any} props */
|
||||
export function SurfaceSelect({ type, value, onChange, name }) {
|
||||
export function SurfaceSelect({ type, value, onChange, name }: any) {
|
||||
return (
|
||||
<div role="radiogroup" aria-label={`Run ${name} as`} className="space-y-1.5">
|
||||
{OPTIONS.map((option) => {
|
||||
@@ -57,8 +57,7 @@ function ConstrainedTag() {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentBadge({ page }) {
|
||||
export function AgentBadge({ page }: any) {
|
||||
const { agent, covers } = useActiveAgent();
|
||||
|
||||
return (
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
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 {
|
||||
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
|
||||
} from '@/lib/agents/runtime';
|
||||
@@ -73,12 +74,30 @@ function writeSelection(selection) {
|
||||
export function AgentProvider({ contextId = null, children }) {
|
||||
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
|
||||
about what exists. */
|
||||
/**
|
||||
* Shipped definitions plus anything this account has authored.
|
||||
*
|
||||
* 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(
|
||||
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
|
||||
[preferences.customAgents, preferences.customSkills]
|
||||
() => readAgentRegistry(stored, { customSkills: preferences.customSkills || [] }).agents,
|
||||
[stored, preferences.customSkills]
|
||||
);
|
||||
|
||||
const [selection, setSelection] = React.useState(() => readSelection());
|
||||
@@ -86,9 +86,8 @@ function FeedbackControls({ feedback, onFeedback }) {
|
||||
}
|
||||
|
||||
export const Message = React.memo(
|
||||
/** @param {any} props */
|
||||
({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null,
|
||||
feedback = null, onFeedback = null }) => {
|
||||
feedback = null, onFeedback = null }: any) => {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<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
|
||||
than hitting sessionStorage sixty times a second. */
|
||||
const setWidthState = React.useCallback((next) => {
|
||||
const setWidthState: any = React.useCallback((next) => {
|
||||
const clamped = clampWidth(next);
|
||||
setWidth(clamped);
|
||||
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
|
||||
* unconditionally and never needs to know the placement rules.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AssistantTrigger({ className = '', size = 'default' }) {
|
||||
export function AssistantTrigger({ className = '', size = 'default' }: any) {
|
||||
const { supported, isOpen, toggle, context } = useAssistantPanel();
|
||||
|
||||
if (!supported) return null;
|
||||
@@ -2,21 +2,24 @@ import * as React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
|
||||
History, Home, Maximize2, Minimize2, PanelRightClose, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import {
|
||||
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
|
||||
useAssignments, useAssignWorkers, useCreateWorkerWithRole, useCreateJobPosting,
|
||||
useGenerateJobDescription,
|
||||
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
|
||||
useUpdateJobPosting,
|
||||
usePreferences, useRoleCategories,
|
||||
} from '@/lib/krowHooks';
|
||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
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 { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { profileForEmail } from '@/lib/skillGraph';
|
||||
@@ -38,7 +41,7 @@ import { PromptChips } from './PromptChips';
|
||||
*
|
||||
* Identity, not emptiness: `prompts` is recomputed on every keystroke, and a
|
||||
* 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 = [];
|
||||
|
||||
@@ -52,41 +55,6 @@ const EMPTY_PROMPTS = [];
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -168,77 +136,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
|
||||
* way out of a long answer should not be something you have to scroll all the
|
||||
* 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.
|
||||
* Extracted because it is the whole of the interaction and every clause is a
|
||||
* decision somebody could reasonably make differently:
|
||||
*
|
||||
* The listener is passive and rAF-throttled, and `setVisible` is only ever
|
||||
* called with a value that can change — React bails out on an identical one, so
|
||||
* a fast scroll costs at most one render per direction change.
|
||||
*
|
||||
* `pinToBottom` is the same follow-the-stream scroll the panel already did,
|
||||
* routed through here so a programmatic jump is not mistaken for the user
|
||||
* scrolling down and does not hide the control under them.
|
||||
* focused — an offer belongs to the thing you are about to type into. An
|
||||
* unfocused composer showing suggestions is the panel talking
|
||||
* first.
|
||||
* !busy — nothing is offered while an answer is still arriving; the next
|
||||
* question is not knowable until this one lands.
|
||||
* chat — History is a different view with a different body.
|
||||
* 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 }) {
|
||||
const [visible, setVisible] = React.useState(true);
|
||||
const lastY = React.useRef(0);
|
||||
export const shouldShowSuggestions = ({ focused, busy, view, count }) => Boolean(
|
||||
focused && !busy && view === 'chat' && count > 0
|
||||
);
|
||||
|
||||
/* Ignore sub-pixel and trackpad jitter, but nothing a deliberate scroll would
|
||||
produce: a real direction change clears this within one frame. */
|
||||
const NOISE = 4;
|
||||
/**
|
||||
* The questions on offer, above the composer they belong to.
|
||||
*
|
||||
* 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;
|
||||
if (!el) return;
|
||||
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]);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,13 +270,21 @@ export default function KrowAssistant({
|
||||
request: panelRequest, consumeRequest,
|
||||
notice: panelNotice, consumeNotice,
|
||||
test: panelTest, reportTest, clearTest,
|
||||
ask: askAssistant,
|
||||
} = useAssistantPanel();
|
||||
|
||||
/* The app's own router, not a location assignment: a full page load would
|
||||
discard the thread and the panel state along with it. */
|
||||
const goToPage = React.useCallback(
|
||||
(destination) => navigate(destination.route),
|
||||
[navigate]
|
||||
(destination, question) => {
|
||||
navigate(destination.route);
|
||||
/* Hand the question to the panel that mounts on the other side. The
|
||||
request lives in AssistantPanelContext, which sits ABOVE the router, so
|
||||
it survives the navigation that discards this panel's thread. Without
|
||||
this the reader has to retype what they just typed. */
|
||||
if (question) askAssistant({ question });
|
||||
},
|
||||
[navigate, askAssistant]
|
||||
);
|
||||
|
||||
/* Skills available here, and the roles they can act on — both read from what
|
||||
@@ -433,6 +430,66 @@ export default function KrowAssistant({
|
||||
return createJob.mutateAsync(result.data);
|
||||
}, [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.
|
||||
*
|
||||
@@ -452,13 +509,16 @@ export default function KrowAssistant({
|
||||
* used here — it was computed before the row existed.
|
||||
*/
|
||||
const queryClient = useQueryClient();
|
||||
const refreshSuggestions = React.useCallback(async () => {
|
||||
const refreshSuggestions = React.useCallback(async ({ query = '' } = {}) => {
|
||||
if (!owliverPage) return [];
|
||||
const fresh = await queryClient.fetchQuery({
|
||||
/* The empty query string is the untyped request — the same key
|
||||
`useOwliverSuggestions` uses when the composer is empty. */
|
||||
queryKey: ['owliverSuggestions', owliverPage, ''],
|
||||
queryFn: () => fetchOwliverSuggestions({ page: owliverPage }),
|
||||
/* An empty query is the untyped request — the same key
|
||||
`useOwliverSuggestions` uses when the composer is empty, and what a
|
||||
write wants: the page changed, so ask what matters on it now.
|
||||
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,
|
||||
});
|
||||
return suggestionChips(fresh || [], context.id);
|
||||
@@ -554,7 +614,13 @@ export default function KrowAssistant({
|
||||
pageLabel: context.page,
|
||||
onNavigate: goToPage,
|
||||
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,
|
||||
onUpdatePosition,
|
||||
onGenerateDescription,
|
||||
@@ -604,12 +670,12 @@ export default function KrowAssistant({
|
||||
const scrollRef = React.useRef(null);
|
||||
const isEmpty = messages.length === 0 && !pending;
|
||||
|
||||
/* The Back to Home row only exists in the states that are not already home. */
|
||||
const showBackRow = view === 'history' || messages.length > 0 || Boolean(pending);
|
||||
const { visible: backVisible, pinToBottom } = useDirectionalNav(scrollRef, {
|
||||
active: showBackRow,
|
||||
resetKey: `${view}:${conversationId || ''}`,
|
||||
});
|
||||
const pinToBottom = usePinToBottom(scrollRef);
|
||||
|
||||
/* Whether there is anything to leave. The header's own control is shown on
|
||||
exactly the states that are not already home — the same test the removed
|
||||
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
|
||||
the data or the page actually changes. */
|
||||
@@ -619,60 +685,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
|
||||
* different owners. Follow-ups belong to the answer that raised them; the
|
||||
* suggestions belong to the server. Only one of them can be true at a time,
|
||||
* and the order below is that precedence.
|
||||
* Three rules, and they are about DIFFERENT questions — what to show, and
|
||||
* whether to show anything at all.
|
||||
*
|
||||
* 1. Follow-ups. When an answer ends by asking something, its chips *are*
|
||||
* the answers to it — the role list after "create a position". They are
|
||||
* never capped and never filtered, and they stand until the next turn or
|
||||
* until the reader starts typing something else.
|
||||
* WHAT. Before the first question, the page's own suggested questions: the
|
||||
* reader has asked nothing, so there is nothing to follow up and the useful
|
||||
* offer is the range of what this page can answer. After an answer, the
|
||||
* 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
|
||||
* composer, `GET /api/v1/owliver/suggestions` is asked what this page
|
||||
* can usefully answer for this query, and its reply is rendered in the
|
||||
* order it arrived. The panel does not rank, score, filter or reorder
|
||||
* it: which readings exist depends on the caller's role and on what is
|
||||
* actually in the database, and neither of those is knowable here.
|
||||
* WHEN. Only while the composer has focus and is empty. Suggestions used to
|
||||
* appear from the second character typed, which is the wrong moment twice
|
||||
* over: a reader who is typing has already decided what to ask, and two
|
||||
* characters is not enough to know what they mean. So typing hides them and
|
||||
* the reader's own text is never touched.
|
||||
*
|
||||
* 3. Nothing. An empty composer offers no chips at all. The panel used to
|
||||
* open on a dozen of them, which taught the range of what could be asked
|
||||
* by saying all of it at once and pushed the composer — the thing the
|
||||
* 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.
|
||||
* The typed-query branch is gone with it. The endpoint still takes a query
|
||||
* and `nextSteps` still uses it — that is what makes a follow-up follow from
|
||||
* something — but nothing asks it on a keystroke any more.
|
||||
*/
|
||||
const followUp = messages[messages.length - 1]?.followUp;
|
||||
const typed = input.trim();
|
||||
const [composerFocused, setComposerFocused] = React.useState(false);
|
||||
|
||||
/**
|
||||
* The request behind (2), debounced.
|
||||
*
|
||||
* 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({
|
||||
/* The page's own questions, for a thread that has not started. One untyped
|
||||
request, cached by the hook, asked only while it could be shown. */
|
||||
const { data: pageSuggestions = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
|
||||
page: owliverPage,
|
||||
query: debouncedQuery,
|
||||
enabled: Boolean(debouncedQuery),
|
||||
enabled: Boolean(owliverPage) && messages.length === 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* 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(() => {
|
||||
if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
||||
return suggestionChips(suggested, context.id);
|
||||
}, [typed, followUp, suggested, context.id]);
|
||||
if (typed) return actionSuggestions(typed, reachableSkills);
|
||||
if (messages.length) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
||||
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. */
|
||||
const lastAnswerIndex = React.useMemo(
|
||||
@@ -825,6 +929,10 @@ export default function KrowAssistant({
|
||||
different places with different affordances. The panel does the
|
||||
first job only; the registry behind it is unchanged. */}
|
||||
<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
|
||||
body, so the layout of the panel is unchanged whether or not
|
||||
there is anything to show. It toggles: pressing it again returns
|
||||
@@ -837,9 +945,6 @@ export default function KrowAssistant({
|
||||
aria-pressed={view === '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
|
||||
? onRestore && (
|
||||
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
|
||||
@@ -853,37 +958,6 @@ export default function KrowAssistant({
|
||||
</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.
|
||||
*
|
||||
@@ -993,27 +1067,19 @@ export default function KrowAssistant({
|
||||
</div>
|
||||
|
||||
{/* ── 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">
|
||||
{/* All suggestions on the landing screen, where they teach what can be
|
||||
asked. Capped once a thread exists, because from then on the vertical
|
||||
space belongs to the conversation. Expanded fits more per line, so it
|
||||
can afford one more.
|
||||
|
||||
Follow-ups are never capped: when Owliver has asked a question, its
|
||||
chips *are* the answers, and hiding three of the six roles would make
|
||||
the flow look broken. */}
|
||||
{!busy && view === 'chat' && (
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={runPrompt}
|
||||
max={isEmpty || followUp?.length ? undefined : expanded ? 4 : 3}
|
||||
align="start"
|
||||
/>
|
||||
)}
|
||||
{/* Focus is tracked on the whole region rather than on the textarea, so
|
||||
reaching for a suggestion does not close the panel out from under the
|
||||
click. In normal flow, never floating: an overlay here would sit on
|
||||
top of the answer, which is the mistake the removed Back to Home row
|
||||
made. The body above is `flex-1`, so it yields the height and the
|
||||
response stays whole and scrollable. */}
|
||||
<div
|
||||
className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5"
|
||||
onFocusCapture={() => setComposerFocused(true)}
|
||||
onBlurCapture={onComposerBlur}
|
||||
>
|
||||
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
|
||||
{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>
|
||||
</Surface>
|
||||
);
|
||||
@@ -23,8 +23,13 @@ import { cn } from '@/lib/utils';
|
||||
*
|
||||
* Arrow keys move between chips, so the whole set is one tab stop.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center', className = '' }) {
|
||||
export function PromptChips({
|
||||
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 visible = max ? prompts.slice(0, max) : prompts;
|
||||
|
||||
@@ -59,6 +64,7 @@ export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center',
|
||||
type="button"
|
||||
onClick={() => onSelect(prompt)}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
tabIndex={focusable ? undefined : -1}
|
||||
title={prompt.prompt}
|
||||
className={cn(
|
||||
/* 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
|
||||
* they do it without a timer or a rerender every four seconds.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function PromptInput({
|
||||
value,
|
||||
onChange,
|
||||
@@ -28,7 +27,7 @@ export function PromptInput({
|
||||
autoFocus = false,
|
||||
align = 'left',
|
||||
className = '',
|
||||
}) {
|
||||
}: any) {
|
||||
const textareaRef = React.useRef(null);
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
|
||||
@@ -22,10 +22,42 @@ import { usePageAction } from './PageContext';
|
||||
* 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 }) {
|
||||
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
|
||||
|
||||
@@ -36,6 +68,33 @@ function Inline({ value }) {
|
||||
if (part.startsWith('_') && part.endsWith('_')) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -62,14 +121,14 @@ const TONE_BADGE = {
|
||||
|
||||
/* ── 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">
|
||||
<Inline value={block.text} />
|
||||
</p>
|
||||
));
|
||||
TextBlock.displayName = 'TextBlock';
|
||||
|
||||
const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
const HeadingBlock = React.memo(({ block }: any) => (
|
||||
<div className="space-y-0.5">
|
||||
<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>}
|
||||
@@ -78,7 +137,7 @@ const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
HeadingBlock.displayName = 'HeadingBlock';
|
||||
|
||||
/** 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">
|
||||
{block.items.map((item) => (
|
||||
<div
|
||||
@@ -113,7 +172,7 @@ const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
KpisBlock.displayName = 'KpisBlock';
|
||||
|
||||
/** 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">
|
||||
{block.items.map((item) => (
|
||||
<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';
|
||||
|
||||
const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
const MetersBlock = React.memo(({ block }: any) => (
|
||||
<div className="space-y-2.5">
|
||||
{block.items.map((item) => (
|
||||
<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
|
||||
* never widens the panel.
|
||||
*/
|
||||
const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const TableBlock = React.memo(({ block }: any) => {
|
||||
const cell = (value) => {
|
||||
if (value == null || value === '') return <span className="text-ink-4">—</span>;
|
||||
if (typeof value === 'object') {
|
||||
@@ -228,7 +287,7 @@ const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
TableBlock.displayName = 'TableBlock';
|
||||
|
||||
/** 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);
|
||||
|
||||
return (
|
||||
@@ -255,7 +314,7 @@ const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
});
|
||||
FunnelBlock.displayName = 'FunnelBlock';
|
||||
|
||||
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const ListBlock = React.memo(({ block }: any) => {
|
||||
const Tag = block.ordered ? 'ol' : 'ul';
|
||||
return (
|
||||
<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
|
||||
* 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">
|
||||
{block.items.map((item, i) => {
|
||||
const surface = cn(
|
||||
@@ -375,7 +434,7 @@ const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt })
|
||||
InsightsBlock.displayName = 'InsightsBlock';
|
||||
|
||||
/** 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">
|
||||
{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">
|
||||
@@ -394,7 +453,7 @@ const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
));
|
||||
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">
|
||||
{block.items.map((item) => (
|
||||
<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';
|
||||
|
||||
const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
const TimelineBlock = React.memo(({ block }: any) => (
|
||||
<ol className="relative space-y-3">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-3">
|
||||
@@ -438,7 +497,7 @@ const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
));
|
||||
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">
|
||||
<Inline value={block.text} />
|
||||
</p>
|
||||
@@ -455,7 +514,7 @@ NoteBlock.displayName = 'NoteBlock';
|
||||
* shape the table does not carry renders nothing rather than something
|
||||
* improvised.
|
||||
*/
|
||||
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const SkillSectionBlock = React.memo(({ block }: any) => {
|
||||
const section = block.section;
|
||||
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
|
||||
* 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 approve = React.useCallback(() => {
|
||||
@@ -614,7 +673,7 @@ const RENDERERS = {
|
||||
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
||||
* 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">
|
||||
{blocks.map((block, i) => {
|
||||
const Renderer = RENDERERS[block.type];
|
||||
@@ -20,7 +20,7 @@ export const doc = (...blocks) => ({
|
||||
export const text = (value) => value && { type: 'text', text: value };
|
||||
|
||||
/** 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? }]` */
|
||||
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 plural = (n, word, irregular) =>
|
||||
export const plural = (n, word, irregular?) =>
|
||||
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
|
||||
|
||||
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),
|
||||
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);
|
||||
acc[label] ||= { label, count: 0 };
|
||||
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';
|
||||
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
|
||||
acc[name].count += 1;
|
||||
@@ -53,7 +53,22 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
||||
return {
|
||||
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, signal }) {
|
||||
/* No agent, no run. The panel resolves which agent covers the page before
|
||||
calling; reaching here without one means the routing layer changed and
|
||||
this should say so rather than guess at an agent id. */
|
||||
@@ -169,7 +184,9 @@ async function* readRunStream(response, signal) {
|
||||
|
||||
if (typeof event.delta === 'string') {
|
||||
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;
|
||||
}
|
||||
if (event.run) final = event.run;
|
||||
@@ -226,6 +243,290 @@ function toBlocks(run) {
|
||||
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.
|
||||
*
|
||||
@@ -247,8 +548,8 @@ function toBlocks(run) {
|
||||
* parser would be a large dependency in exchange for handling footnotes nobody
|
||||
* writes.
|
||||
*/
|
||||
export function markdownToBlocks(markdown) {
|
||||
const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
|
||||
export function markdownToBlocks(markdown, { partial = false } = {}) {
|
||||
const lines = stripCitations(markdown, { partial }).replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks = [];
|
||||
let paragraph = [];
|
||||
let listItems = null;
|
||||
@@ -353,7 +654,15 @@ function parseTable(lines, start) {
|
||||
* left alone — those go through `Inline`, which renders bold properly.
|
||||
*/
|
||||
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 +747,7 @@ export function createAssistantProvider() {
|
||||
export function createUnconfiguredProvider() {
|
||||
return {
|
||||
id: 'unconfigured',
|
||||
// eslint-disable-next-line require-yield
|
||||
|
||||
async *stream() {
|
||||
yield [note(
|
||||
'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 { resolveUiEdit } from './uiEdit';
|
||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||
import { poolFor } from '@/lib/workforce';
|
||||
import { matchSkill } from '@/lib/skills/registry';
|
||||
@@ -23,7 +24,8 @@ import {
|
||||
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
|
||||
extractSkillName, findCourseByName,
|
||||
} 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.
|
||||
@@ -159,7 +161,7 @@ export function answerableHere(context, question) {
|
||||
export function navigationAnswer(destination) {
|
||||
return doc(
|
||||
text(`That is on **${destination.page}**. Taking you there now.`),
|
||||
note('Ask again once the page loads and I will answer from it.')
|
||||
note('Bringing your question with me — I will answer it from that page.')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -633,7 +635,7 @@ function declaredAnswer({ skill, capability, question, skillContext }) {
|
||||
*/
|
||||
function resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
skillContext = null,
|
||||
companies = [], postings = null, skillContext = null,
|
||||
}) {
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* 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') {
|
||||
return { kind: 'skill', skill, ...beginPositionFlow({ question, skill, roles }) };
|
||||
const registry = flowFor(skill);
|
||||
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({
|
||||
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
||||
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.
|
||||
*
|
||||
@@ -869,6 +882,14 @@ export function resolveIntent({
|
||||
was one. Only the draft flow reads it; a typed question carries none and
|
||||
resolves exactly as it always did. */
|
||||
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;
|
||||
|
||||
@@ -900,9 +921,21 @@ export function resolveIntent({
|
||||
const draftIntent = resolveDraftAction(question, workforce || { positions: [] }, positionId);
|
||||
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. */
|
||||
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:
|
||||
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. */
|
||||
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,
|
||||
} from '@/lib/krowHooks';
|
||||
import { skillsForContext } from '@/lib/skills/registry';
|
||||
import {
|
||||
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
||||
} from '@/lib/skills/positionFlow';
|
||||
import { advanceFlow } from '@/lib/skills/conversationFlow';
|
||||
import { flowFor } from '@/lib/skills/flows';
|
||||
import {
|
||||
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
|
||||
publishedReply, weightsSetReply, weightsUnchangedReply,
|
||||
@@ -186,6 +185,60 @@ function normalizeDraftChip(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
|
||||
* anything this thread has asked or already offered 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.
|
||||
* Removing what has been used leaves genuinely new ground each time and runs out
|
||||
* honestly rather than looping.
|
||||
*
|
||||
* 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;
|
||||
|
||||
const used = new Set([asQuestion(question)]);
|
||||
for (const message of history) {
|
||||
if (message.role === 'user') used.add(asQuestion(message.text));
|
||||
for (const chip of message.followUp || []) used.add(asQuestion(chip.prompt || chip.label));
|
||||
}
|
||||
|
||||
const unused = (chips) => (chips || []).filter((chip) => {
|
||||
const key = asQuestion(chip.prompt || chip.label);
|
||||
if (!key || used.has(key)) return false;
|
||||
/* A list that repeats itself within one turn is the same defect at a
|
||||
smaller scale. */
|
||||
used.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.
|
||||
*
|
||||
@@ -237,7 +290,24 @@ function withoutAuthoringActions(messages = []) {
|
||||
* routing applies to both without either knowing it exists.
|
||||
*/
|
||||
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,
|
||||
/* Finishing a draft: the same two mutations the Create Position form calls.
|
||||
Passed in rather than reached for, so this layer still writes nothing
|
||||
@@ -245,6 +315,15 @@ export function useConversation({
|
||||
onUpdatePosition, onGenerateDescription,
|
||||
workforce = null, disabledSkills = [], customSkills = [],
|
||||
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.
|
||||
*
|
||||
@@ -475,7 +554,10 @@ export function useConversation({
|
||||
intent = {
|
||||
kind: 'flow',
|
||||
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 +572,28 @@ export function useConversation({
|
||||
question: text,
|
||||
contextId: turnContext,
|
||||
disabledSkills: turnDisabled,
|
||||
customSkills, roles, skillCategories,
|
||||
customSkills, roles, skillCategories, companies, postings,
|
||||
courses, workforce, skillContext, positionId,
|
||||
agent: turnAgent,
|
||||
agentCoversPage: turnCovers,
|
||||
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
|
||||
simulator there is nothing better to defer TO, and deferring
|
||||
@@ -503,19 +602,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,
|
||||
* because the reply is the outcome — "Position created successfully" has to
|
||||
* be true when it is said.
|
||||
*/
|
||||
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;
|
||||
/* 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
|
||||
required field, a refused role, or an API that is not running. */
|
||||
let failure = null;
|
||||
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) {
|
||||
created = null;
|
||||
failure = error;
|
||||
@@ -551,19 +687,16 @@ export function useConversation({
|
||||
intent = {
|
||||
...intent,
|
||||
flow: null,
|
||||
doc: positionCreatedReply(created),
|
||||
followUp: [...createdFollowUp(created), ...refreshed],
|
||||
doc: registry.outcome.created(created),
|
||||
followUp: [...registry.outcome.followUp(created), ...refreshed],
|
||||
};
|
||||
} else {
|
||||
/* Keep the answers: the summary is still there to try again from. */
|
||||
intent = {
|
||||
...intent,
|
||||
flow: { ...intent.flow, stage: 'review' },
|
||||
doc: positionFailedReply(failure?.message),
|
||||
followUp: [
|
||||
{ label: 'Create position', prompt: 'Create position' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
doc: registry.outcome.failed(failure?.message),
|
||||
followUp: registry.outcome.retryChips,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -723,12 +856,20 @@ export function useConversation({
|
||||
abortRef.current = null;
|
||||
}
|
||||
|
||||
/* Navigate after the reply is on screen, so the user reads why they moved.
|
||||
The panel re-resolves its context from the new route, which is what
|
||||
makes the next question answer from the page they land on. */
|
||||
/* Navigate after the reply is on screen — except the reply does not
|
||||
survive the move. The panel is page-scoped, so it re-mounts on the new
|
||||
route and the message explaining why the reader moved is destroyed by
|
||||
the navigation that message was explaining. The reader landed somewhere
|
||||
else with a fresh greeting and no trace of what they asked.
|
||||
|
||||
So the question travels with the destination. The panel on the other
|
||||
side asks it, which is what the reader wanted in the first place and
|
||||
what the old copy ("ask again once the page loads") was apologising
|
||||
for. Re-asking cannot loop: resolveIntent only navigates when the
|
||||
destination differs from the current page. */
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (intent.kind === 'navigate') onNavigate?.(intent.destination);
|
||||
if (intent.kind === 'navigate') onNavigate?.(intent.destination, text);
|
||||
/* A skill's action runs after its reply, for the same reason: the user
|
||||
should read why the form opened before it opens. */
|
||||
if (intent.kind === 'skill' && intent.action) onAction?.(intent.action, intent.skill);
|
||||
@@ -757,9 +898,53 @@ export function useConversation({
|
||||
// half-written answer loses what they were already reading. Stopping
|
||||
// before the first block, though, should leave no empty turn behind.
|
||||
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 = [
|
||||
...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;
|
||||
persist(next);
|
||||
@@ -777,13 +962,18 @@ export function useConversation({
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
|
||||
}, [contextId, facts, persist, onNavigate, onAction, flowWriters, onRefreshSuggestions,
|
||||
onUpdatePosition,
|
||||
onGenerateDescription, onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses, skillContext,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext]);
|
||||
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext,
|
||||
/* 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(), []);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from '@/components/ds/props';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
@@ -104,7 +105,7 @@ function getDeptColor(index, avgScore) {
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function DepartmentPerformance({ items = [], className = '' }) {
|
||||
export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
|
||||
const [activeDept, setActiveDept] = useState(null);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from '@/components/ds/props';
|
||||
import React from 'react';
|
||||
import { BarChart } from '@mui/x-charts';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
@@ -19,7 +20,7 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||||
* component derives no figures of its own.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }) {
|
||||
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }: DsProps) {
|
||||
if (!stages.length) return null;
|
||||
|
||||
const total = stages[0]?.count || 1;
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from '@/components/ds/props';
|
||||
import React from 'react';
|
||||
import { LineChart } from '@mui/x-charts';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -15,7 +16,7 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||||
* `points: [{ label, hires, cumulative }]` — supplied by the page.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function HiringTrendChart({ points = [], emptyState, className = '' }) {
|
||||
export function HiringTrendChart({ points = [], emptyState, className = '' }: DsProps) {
|
||||
const enough = points.length >= 2;
|
||||
|
||||
return (
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar } from '@/components/ds/Avatar';
|
||||
@@ -30,7 +31,7 @@ export function ActivityCard({
|
||||
variant = 'row',
|
||||
onClick,
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const iconTones = {
|
||||
soft: 'bg-krow-blue-tint text-krow-blue',
|
||||
brand: 'bg-krow-blue text-white',
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
@@ -40,7 +41,7 @@ const ICON_COLORS = {
|
||||
* that persists is noise.
|
||||
*/
|
||||
/** @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];
|
||||
|
||||
return (
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
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
|
||||
* degrades to initials instead of a broken-image icon.
|
||||
*/
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
export const Avatar = React.forwardRef(
|
||||
export const Avatar: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
|
||||
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
|
||||
const [failed, setFailed] = React.useState(false);
|
||||
const showImage = src && !failed;
|
||||
@@ -91,7 +91,7 @@ Avatar.displayName = 'Avatar';
|
||||
* people in a tight row (applicants on a position, endorsers on a skill).
|
||||
*/
|
||||
/** @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 overflow = people.length - visible.length;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { ResponsiveContainer } from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -38,7 +39,7 @@ export const AXIS_PROPS = {
|
||||
* white box that ignores the token set.
|
||||
*/
|
||||
/** @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;
|
||||
|
||||
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.
|
||||
* Items: `{ label, color, value? }`.
|
||||
*/
|
||||
export function ChartLegend({ items = [], className }) {
|
||||
export function ChartLegend({ items = [], className }: DsProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
|
||||
{items.map((item) => (
|
||||
@@ -118,7 +119,7 @@ export function ChartContainer({
|
||||
raw = false,
|
||||
footer,
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
if (loading) return <SkeletonChart className={className} height={height} />;
|
||||
|
||||
return (
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -60,7 +61,7 @@ export function DataTable({
|
||||
className,
|
||||
rowClassName,
|
||||
stickyHeader = false,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
/* ── Sorting ─────────────────────────────────────────────────────────── */
|
||||
/* Row height follows the account's density preference. Only the vertical
|
||||
padding changes — same columns, same type, same behaviour. */
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
@@ -41,7 +42,7 @@ export function Drawer({
|
||||
className,
|
||||
/** Hides the header for a fully custom panel (the mobile nav does this). */
|
||||
bare = false,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const isVertical = side === 'top' || side === 'bottom';
|
||||
|
||||
return (
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -25,7 +26,7 @@ export function EmptyState({
|
||||
variant = 'empty',
|
||||
size = 'default',
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const renderAction = (spec, buttonVariant) => {
|
||||
if (!spec) return null;
|
||||
if (React.isValidElement(spec)) return spec;
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -19,7 +20,7 @@ export function Field({
|
||||
inline = false,
|
||||
className,
|
||||
children,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const reactId = React.useId();
|
||||
const id = htmlFor || reactId;
|
||||
const hintId = hint ? `${id}-hint` : undefined;
|
||||
@@ -27,11 +28,15 @@ export function Field({
|
||||
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
|
||||
|
||||
// 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)
|
||||
? React.cloneElement(children, {
|
||||
id: children.props.id || id,
|
||||
'aria-describedby': children.props['aria-describedby'] || describedBy,
|
||||
'aria-invalid': error ? true : children.props['aria-invalid'],
|
||||
? React.cloneElement(children as React.ReactElement<Record<string, any>>, {
|
||||
id: (children.props as Record<string, any>).id || id,
|
||||
'aria-describedby': (children.props as Record<string, any>)['aria-describedby'] || describedBy,
|
||||
'aria-invalid': error ? true : (children.props as Record<string, any>)['aria-invalid'],
|
||||
})
|
||||
: children;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { SlidersHorizontal, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -37,7 +38,7 @@ export function FilterBar({
|
||||
/** Value that means "no filter" for select-type filters. */
|
||||
allValue = 'all',
|
||||
className = '',
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
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
|
||||
* makes it impossible to omit. The label doubles as the tooltip.
|
||||
*/
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
export const IconButton = React.forwardRef(
|
||||
export const IconButton: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
|
||||
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
|
||||
const sizeMap = {
|
||||
xs: 'icon-xs',
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -58,7 +59,7 @@ export function InsightRow({
|
||||
onClick = null,
|
||||
actionLabel = 'Review',
|
||||
className = '',
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const s = SEVERITY[severity] || SEVERITY.info;
|
||||
const Comp = onClick ? 'button' : 'div';
|
||||
|
||||
@@ -107,6 +108,6 @@ export function InsightRow({
|
||||
|
||||
/** The list container — dividers between rows, nothing else. */
|
||||
/** @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>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
||||
@@ -32,7 +33,7 @@ export function KpiCard({
|
||||
loading = false,
|
||||
onClick,
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const t = TONES[tone] || TONES.brand;
|
||||
|
||||
if (loading) {
|
||||
@@ -112,7 +113,7 @@ export function MetricCard({
|
||||
loading = false,
|
||||
onClick,
|
||||
className,
|
||||
}) {
|
||||
}: DsProps) {
|
||||
const t = TONES[tone] || TONES.neutral;
|
||||
|
||||
if (loading) {
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
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).
|
||||
*/
|
||||
/** @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 = {
|
||||
1: 'grid-cols-1',
|
||||
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. */
|
||||
/** @param {any} props */
|
||||
export function Stack({ space = 'default', className, children, ...props }) {
|
||||
export function Stack({ space = 'default', className, children, ...props }: DsProps) {
|
||||
const spacing = {
|
||||
xs: 'space-y-2',
|
||||
sm: 'space-y-3',
|
||||
@@ -59,7 +60,7 @@ export function Stack({ space = 'default', className, children, ...props }) {
|
||||
* with its content at consistent spacing.
|
||||
*/
|
||||
/** @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 (
|
||||
<section className={cn('space-y-3', className)}>
|
||||
{title && (
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -6,7 +7,7 @@ import { cn } from '@/lib/utils';
|
||||
* Spinner — the only spinner in the system.
|
||||
*/
|
||||
/** @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' };
|
||||
return (
|
||||
<Loader2
|
||||
@@ -25,7 +26,7 @@ export function Spinner({ size = 'default', className = '', label = 'Loading' })
|
||||
* final layout prevents the content jump a spinner always causes.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function LoadingState({ message = '', size = 'default', className = '' }) {
|
||||
export function LoadingState({ message = '', size = 'default', className = '' }: DsProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -45,7 +46,7 @@ export function LoadingState({ message = '', size = 'default', className = '' })
|
||||
* card headers, or beside a control that is refreshing.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function InlineLoading({ message = 'Loading…', className = '' }) {
|
||||
export function InlineLoading({ message = 'Loading…', className = '' }: DsProps) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
|
||||
<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.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function LoadingOverlay({ show = false, message = '', className = '' }) {
|
||||
export function LoadingOverlay({ show = false, message = '', className = '' }: DsProps) {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<div
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -58,7 +59,7 @@ function Delta({ delta, label, invert }) {
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function MetricStrip({ items = [], columns, className, loading = false }) {
|
||||
export function MetricStrip({ items = [], columns, className, loading = false }: DsProps) {
|
||||
const count = columns ?? Math.min(items.length, 6);
|
||||
|
||||
const gridCols = {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user