refactor(ts-migration): Phase 11 batch 7 — the assistant's non-component modules

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
This commit is contained in:
2026-09-18 15:35:15 +05:30
parent dde4ba62c6
commit 3f835eee93
11 changed files with 6 additions and 4 deletions

View 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".')
);
}