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