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:
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".')
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user