Two changes to the assistant panel.
THE LANGUAGE SELECTOR. An account setting in the header menu, not a property of
the conversation: somebody who reads in Spanish reads in Spanish on every page
and after every reload, and making it per-thread would ask them to set it again
each time the panel opened. Held in localStorage and outside React, so the
Owliver panel — a different subtree from the header — sees the change without a
provider spanning both, and a second tab picks it up.
A TAG is sent, never a sentence, and the field is omitted entirely when the
choice is English. The backend maps it onto a closed set
(internal/runtime/language.go holds the same list) and an unrecognised tag
answers in English. The panel therefore cannot write prompt text from here,
which is the point: the selected string SELECTS a directive rather than
becoming one.
THE SUGGESTION ROW. It appeared exactly once per conversation and was silent
after that, whatever was asked. Every chip ever SHOWN was banned permanently in
a set that only grew; the server keeps returning the top of the same small
catalogue, so by the second turn every suggestion was already in it, the filter
emptied the list, and an empty list draws no row.
Asked and offered are not the same thing. A question this thread actually PUT
is excluded for good — it has an answer on screen and offering to repeat it is
not a follow-up. A chip merely DISPLAYED and passed over is held back only from
the turn directly after it, which is enough to stop the row redrawing verbatim
under consecutive answers; beyond that it is offerable again, because ignoring
a suggestion is not the same as having covered it.
Verified: 1710/1710 skill-checks, a clean typecheck, build and lint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
These six pre-date the migration. They were unfixable while the files
holding them were JavaScript with `checkJs`, because the fix in each case
is an annotation JSDoc cannot express. `src/` is TypeScript now, so:
- `setWidthState.timer` in `AssistantPanelContext` is a debounce handle
hung on the callback itself, so it survives re-renders without a ref.
TypeScript has no way to describe an expando on a `useCallback`
result, so the binding is `any`.
- `runAction(name, { skill, ...payload } = {})` — the `= {}` default
types the parameter `{}`, so reading `skill` off it looked wrong.
- `Progression` in `Positions` is a local presentational component
whose `className` is omitted at both call sites, like its siblings in
the same file.
- `normalizeSection`'s `page` reads as required because it has no
default, but `owliverConfig` deliberately calls it without one: `page`
only builds a fallback `where` label, and that caller passes `where`
explicitly, so the branch that would read it never runs. Stating the
parameter object makes `page` optional, which is what the function has
always accepted.
`npm run typecheck` is now 0 errors, from 71 when this migration started
and 20 when Phase 11 began. Nothing was suppressed to reach it: there is
no `@ts-ignore` or `@ts-expect-error` added anywhere in this branch.
4/4 byte-identical, suite and bundle unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
The thirteen `.jsx` files under `src/components/ai-assistant/`, which
completes that directory: 24/24 files migrated and all 24 linted under
their new extensions.
39 errors, and converting 21 `/** @param {any} props */` hatches to
`: any` cleared 33 of them — the same pattern as batch 6, and the same
reason: without the JSDoc, TypeScript infers every destructured prop as
required, so `ResponseBlocks` and `AssistantMessage` alone produced 31
complaints about call sites that were always correct.
The remaining six were already there before this batch and are
unchanged.
13/13 erase byte-identically. `.jsx` -> `.tsx` is the move that can drop
unused imports through esbuild's loader difference; it did not here, and
the bundle hash is unchanged.
Measured against `3f835ee`:
typecheck 6 errors, unchanged; no new error anywhere
lint exit 0, 0 errors, 289 warnings, 24/24 linted by name
npm test 1684/1691, the same 7 failures verbatim
build exit 0, identical bundle hash 74d17e2d…
type erasure 96/96 byte-identical across Phase 11 so far
No baseline artifact touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
The eleven `.js` files under `src/components/ai-assistant/`: the blocks
format, contexts, routing, history, placement, viewport, the greeting
and prompt tables in `dynamic`, the derivations in `insights`, and
`uiEdit`, whose boundary batch 1 already typed.
79 errors, and two optional markers cleared 63 of them.
`plural(n, word, irregular)` is called with two arguments sixty times in
`dynamic.ts` and its own body reads `irregular || \`${word}s\``, so the
third parameter has always been optional in everything but the
signature. `heading(value, sub)` is the same: `sub` is spread into the
block and `undefined` is what most callers mean. Marking both optional
is a statement about the existing contract, and the markers erase — the
emitted signatures still read `plural=(n,word,irregular)` and
`heading=(value,sub)`, checked in the output rather than assumed.
Those three `heading` errors landed in `lib/skills/workforceFlow.ts`,
already migrated and untouched here. Worth noting how that works: a
function's arity only starts being enforced on its callers once the file
defining it is TypeScript. Migrating a leaf makes claims about every
file that imports it, which is why this phase moves bottom-up.
The remaining nine were two `reduce` accumulators inferring `{}`, so
`Object.values` over them produced `unknown`. Both are now stated —
`{ label, count }` for the score bands, and the five-field hire grouping
— which is more useful than `any` and exactly what the lines below them
build.
Measured against `dde4ba6`:
typecheck 6 errors, unchanged; no new error anywhere
lint exit 0, 0 errors, 289 warnings
npm test 1684/1691, the same 7 failures verbatim
build exit 0, identical bundle hash 74d17e2d…
type erasure 83/83 byte-identical across Phase 11 so far
No baseline artifact touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
Authored in a parallel session alongside the TypeScript migration; committed
separately so the two never share a commit. No TypeScript migration file is
included here.
Candidates becomes the queue of hiring decisions waiting on a person, rather
than a second Talent Pool listing every application the org ever took. Final
selection is DERIVED - there is no `final_selection` value in the
`application_status` enum and none is added. The fact it reads is the existence
of an interview row, a NOT NULL foreign key, rather than
`job_applications.interview_id`, which the schema keeps as an unconstrained soft
reference precisely so it may dangle. `status = 'interview'` is set both when an
interview is arranged and when one is completed, so status alone cannot tell a
queue of people who have been interviewed from a queue of people merely booked
in.
Seats on a position are counted from the employment records instead of a stored
column. A `filled` counter would be a second source of truth, and the day it
disagreed with `staff` nothing could say which was lying. Someone who has left
frees their seat, and over-hiring floors at zero rather than going negative.
`DEMO_FILL` is gone. `hiringRecords.js` padded the hires list with five invented
people 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. Hires now come only from `staff`.
Both paths that file an application on somebody's behalf now carry
`worker_profile_id`, the link back to the talent-pool record. The column is
nullable, so omitting it saved cleanly and failed silently: the application
belonged to an email address rather than to a person, and the hire it became
could not be traced back to the profile it came from.
`HiredChronology` used to `return null` with no hires, taking the `chronology`
node identity out of the DOM with it - so on an honest empty dataset the section
could not be addressed by Owliver or the layout editor at all. It now renders an
empty state inside the section it keeps.
Nine new checks cover the above; `npm test` reports 1684/1691.
SIX SSR PARITY CHECKS FAIL ON PURPOSE, and `scripts/__baseline__/README.md`
documents each with verified tag counts. Five are the `DEMO_FILL` removal: the
Hired History and Analytics baselines were captured while the padding was in
effect and, because they render with queries disabled, the padding is all they
contain. The sixth is this change to what Candidates says. Do not regenerate
those baselines to clear them - two of the checks exist to prove the UI node
tree migration added exactly two `<div>`s, and that proof needs the baseline to
be pre-migration markup. Recapturing now would write post-migration markup into
a file named `pre-migration` and the check would compare the current render
against itself forever. The debt is held until the migration work lands, when
both files are recaptured together.
The seventh failure, the stale backend seed fixture, predates all of this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
`positionFlow.js` was five per-domain concerns in one file — a field table, an
`@`-token resolver, a sentence extractor, a commit vocabulary and a set of
outcome renderers — and only the control flow between them was general.
Everything else knew it was creating a job posting. Adding a second
conversation meant a second copy of all of it.
So the control flow is now `conversationFlow.js` and each kind of record is a
REGISTRY. Which conversation a skill runs is the skill's own `flow:` line,
resolved through a map: `routing.js` used to say `if (skill.id ===
'create-position')`, which made a second conversational skill a change to the
router rather than a file on disk — the `if agent_key == ...` shape the
platform rules out one level up. The panel's write callback is likewise a map
keyed by flow id instead of an `onCreatePosition` prop, and the outcome wording
comes off the registry, so nothing in the panel names a kind of record any
more.
`create-employee-role` is the second registry. The worker is asked for and
never assumed: a conversation that names nobody re-asks rather than falling
back to the session, because an operator records this on somebody's behalf. Its
`extract` is deliberately narrower than the posting's — "bartender, weekends,
$30/hr" settles three fields and leaves the subject alone, since guessing WHO a
record is about from a fragment is how a role gets filed against the wrong
person.
THE CONFIRMATION STEP ACCEPTED "create position" AND SILENTLY REJECTED "create
positions" — the plural the Positions page itself uses. An anchored regex missed
it, and the reader got the summary back with no indication of what was wrong
with what they said, which is indistinguishable from the screen not having
updated. Matching is now exact membership against a normalized reply, so a
vocabulary is a list of phrases somebody can read rather than an expression
somebody has to parse.
Two bugs in `extractRole`, both of which fabricated a value nobody typed on the
one field a position cannot be created without:
- The phrase pattern marks "new" as the role by the same grammar that marks
"sous chef", so "create new position" opened the conversation titled "New".
The scaffolding is a PHRASE at least as often as a single word, so a
per-word test still produced "Brand New" and "One More". Scaffolding words
are now stripped to DECIDE whether the phrase named anything, and the
ORIGINAL phrase is returned when it did — strip to test, never to rewrite,
or "second chef" becomes "Chef" and the cure is worse than the bug.
- `(?:a|an)?\s*` has no word boundary, so it matched the leading "a" of
"another" and the capture began mid-word. That mangled scaffolding into
"Nother New" and, worse, corrupted every role introduced with "an":
"create an open kitchen lead position" titled the position "N Open Kitchen
Lead". A real role, typed correctly, silently wrong. Found by mutation
testing the first fix.
`@companies` and `@workers` resolve from data the panel already holds — postings
and profiles the API has already scoped to the caller — so neither widens
anybody's view and neither costs a request. The company list is deliberately
unsorted: `useJobPostings` asks for `-created_date`, so the clients staffed for
most recently come first, and the panel does no ranking of its own. That last
part is a rule the suite enforces structurally, and it is the right rule — a
second opinion formed in the panel outranking the server's is exactly the kind
of thing that decays quietly.
`npm test` now refuses a conversation step whose field its registry does not
define. `stepsOf` drops unknown fields, so a typo means the flow asks fewer
questions than the file lists — and a skill whose steps are ALL unknown asks
none, jumps to the summary, and offers to write an empty record. Nothing errors
and the Markdown still reads correctly. 970 checks, up from 924; the new ones
walk both conversations end to end, because a wrong answer at the confirmation
step re-renders the same summary a right answer does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
TWO fixes, both found by using the product rather than reading it.
A question asked on a page that cannot answer it was lost. resolveIntent routes
to the page that can, and routing.js answered "That is on Candidates. Taking
you there now. Ask again once the page loads." — but the panel is page-scoped,
so it re-mounts on the new route and the navigation destroys the very message
explaining why the reader moved. What the reader saw was a different page and a
fresh greeting, with no trace of what they asked.
The question now travels with the destination. AssistantPanelContext sits ABOVE
the router and already has `ask` for exactly this — a page handing a question to
the panel — so the panel that mounts on the other side asks it. That is what
the reader wanted, and what "ask again once the page loads" was apologising for.
It cannot loop: resolveIntent only routes when the destination differs from the
page you are on, and answerableHere short-circuits before that.
The Test tab did not test. "Simulation & Scope Diagnostics" reads where a
question WOULD route — which skills are reachable, which tools are in scope,
what the classifier makes of it — and never calls the model. That is genuinely
useful and it is not what a tab called Test leads anyone to expect. A real test
did exist, but under Skills, as "Test in Owliver" on a capability card.
So the Simulated User Query the tab has always shown is now runnable, through
the same path that card uses: the existing Owliver panel, scoped to the draft's
own agent and disabled skills, so the answer comes from the agent being edited
rather than the published one. The diagnostics stay — they answer a different
and still useful question. The button is disabled when the agent does not cover
the selected page, with the reason in its title, rather than offering a run that
would decline.
924/924 checks pass and the production build is clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g