diff --git a/package.json b/package.json index 8d7bf15..cdff3be 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "vite build", "lint": "eslint . --quiet", "lint:fix": "eslint . --fix", + "test": "node scripts/skill-check.mjs", "typecheck": "tsc -p ./jsconfig.json", "preview": "vite preview" }, diff --git a/public/owliver-avatar.png b/public/owliver-avatar.png new file mode 100644 index 0000000..5cc2a3a Binary files /dev/null and b/public/owliver-avatar.png differ diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index 29f71cf..3cf20c2 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -375,9 +375,432 @@ for (const n of [1, 4, 9]) { ); } +/* ── 6. Authoring: what the editors save, and what they refuse ────────────── */ +console.log('\n── Authoring ──'); + +const fields = await server.ssrLoadModule('/src/lib/skills/skillFields.js'); +const templates = await server.ssrLoadModule('/src/lib/skills/customSkills.js'); +const surfaces = await server.ssrLoadModule('/src/lib/skills/surfaces.js'); + +/* A template must produce something that works before it is edited. The Board + template used to default to a source its own default placement cannot read. */ +record( + 'the Board template validates as written', + reg.validateSkillSource(templates.uiSkillTemplate({ + id: 'template-check', name: 'Template Check', pages: ['positions'], + })) === null, + reg.validateSkillSource(templates.uiSkillTemplate({ + id: 'template-check', name: 'Template Check', pages: ['positions'], + })) || 'valid' +); + +/* An Owliver block that can answer nothing is refused rather than saved and + then reported as a skill that does not work. */ +record( + 'an Owliver skill with no capabilities is refused', + Boolean(reg.validateSkillSource(templates.owliverSkillTemplate({ + id: 'empty-owliver', name: 'Empty Owliver', pages: ['positions'], + }))), + reg.validateSkillSource(templates.owliverSkillTemplate({ + id: 'empty-owliver', name: 'Empty Owliver', pages: ['positions'], + })) || 'ACCEPTED — should have been refused' +); + +/* The section-can-never-resolve check, in both directions. */ +const boardOn = (page, placement) => `--- +id: context-check +name: Context Check +description: Reads position activity. +pages: + - ${page} +status: active +ui: + type: flow + placement: ${placement} + source: position.activity +--- + +# Context Check +`; + +record( + 'a position source on a page with no position is refused', + Boolean(reg.validateSkillSource(boardOn('analytics', 'after-header'))), + reg.validateSkillSource(boardOn('analytics', 'after-header')) || 'ACCEPTED — should have been refused' +); +record( + 'the same source is refused above the Positions grid', + Boolean(reg.validateSkillSource(boardOn('positions', 'after-position-list'))), + reg.validateSkillSource(boardOn('positions', 'after-position-list')) || 'ACCEPTED — should have been refused' +); +record( + 'and accepted inside a position card', + reg.validateSkillSource(boardOn('positions', 'after-position-card')) === null, + reg.validateSkillSource(boardOn('positions', 'after-position-card')) || 'accepted' +); + +/* Owliver responses are deliberately NOT subject to that rule: an unmet need is + a question back, not a dead card. `hiring-activity-assistant` depends on it. */ +record( + 'a shipped Owliver skill reading one position still validates', + reg.validateSkillSource( + reg.SKILLS.find((s) => s.id === 'hiring-activity-assistant').markdown + ) === null +); + +record( + 'every shipped definition still validates', + reg.SKILLS.every((s) => reg.validateSkillSource(s.markdown) === null), + reg.SKILLS.filter((s) => reg.validateSkillSource(s.markdown)).map((s) => s.id).join(', ') || 'all valid' +); + +/* Reading a definition into the editor fields — the upload path. */ +for (const skill of reg.SKILLS.filter((s) => s.kind === 'assistant')) { + const read = fields.owliverFieldsFromSource(skill.markdown); + record( + `\`${skill.id}\` reads back into the Owliver fields`, + read.id === skill.id && read.name === skill.name + && read.pages.join(',') === skill.pages.join(','), + `${read.name} / ${read.pages.join(', ')}` + ); +} + +record( + 'an unparseable file leaves the fields empty rather than throwing', + fields.owliverFieldsFromSource('not a definition').id === '' +); + +/* Writing a field back — the half that used to do nothing. */ +const original = reg.SKILLS.find((s) => s.id === 'hiring-activity-assistant').markdown; +const renamed = fields.patchFrontmatter(original, { name: 'Renamed Assistant' }); + +record( + 'patching a field changes what the registry reads', + reg.parseSkill(renamed, { custom: true }).name === 'Renamed Assistant', + reg.parseSkill(renamed, { custom: true }).name +); +record( + 'patching a field leaves the body untouched', + reg.parseSkill(renamed, { custom: true }).body === reg.parseSkill(original, { custom: true }).body +); +record( + 'patching a field preserves frontmatter comments', + renamed.includes('# One suggestion per capability.') +); +record( + 'patching a field changes nothing else', + original.split('\n').filter((l) => !l.startsWith('name:')).join('\n') + === renamed.split('\n').filter((l) => !l.startsWith('name:')).join('\n') +); +record( + 'a patched definition still validates', + reg.validateSkillSource(renamed) === null, + reg.validateSkillSource(renamed) || 'valid' +); + +const repaged = fields.patchFrontmatter(original, { pages: ['analytics', 'activity'] }); +record( + 'patching a list replaces the whole block', + reg.parseSkill(repaged, { custom: true }).pages.join(',') === 'analytics,activity', + reg.parseSkill(repaged, { custom: true }).pages.join(',') +); + +const restatused = fields.patchFrontmatter(original, { status: 'inactive' }); +record( + 'patching a key the file never declared adds it', + reg.parseSkill(restatused, { custom: true }).status === 'inactive' +); + +/* Both keys, because that is what the editor's own field handler writes: the + registry reads capabilities as the union of the declared list and the keys of + `responses:`, so patching one without the other changes nothing. */ +const recapped = fields.patchFrontmatter(original, { + 'owliver.capabilities': ['summary'], + 'owliver.responses': { summary: { source: 'position.activity', periods: ['today'] } }, +}); +record( + 'patching a nested block rewrites only that block', + reg.parseSkill(recapped, { custom: true }).owliver.capabilities.join(',') === 'summary', + reg.parseSkill(recapped, { custom: true }).owliver.capabilities.join(',') +); +/* Asserted on the text, not on the parse: with `flow` no longer offered, the + registry correctly drops the suggestion that names it — which is the rule + working, not the patch reaching a sibling key it should not have. */ +record( + 'and leaves its siblings in the same block alone', + recapped.includes('Summarize hiring activity for this position') + && recapped.includes('Show hiring activity as a flow') + && recapped.includes(' enabled: true') +); + +/* The two `ui:` shapes, told apart — the per-page form must not be overwritten + from four single-valued fields. */ +record( + 'the shorthand `ui:` form is recognised', + fields.uiShape(boardOn('positions', 'after-position-card')) === 'shorthand' +); +record( + 'a definition with no `ui:` block reports none', + fields.uiShape(original) === 'none' +); + +/* The picker and the validator read the same table. */ +record( + 'a position card supplies a position', + surfaces.contextSuppliedBy(['positions'], 'after-position-card').includes('positionId') +); +record( + 'the Positions list supplies nothing', + surfaces.contextSuppliedBy(['positions'], 'after-position-list').length === 0 +); +record( + 'Analytics supplies nothing', + surfaces.contextSuppliedBy(['analytics'], 'after-header').length === 0 +); +record( + 'every surface declares what its placements provide', + surfaces.SKILL_SURFACES.every((s) => s.provides && typeof s.provides === 'object'), + surfaces.SKILL_SURFACES.filter((s) => !s.provides).map((s) => s.id).join(', ') || 'all declared' +); + +/* ── 7. Upload hydration: a file, into the fields ─────────────────────────── */ +console.log('\n── Upload hydration ──'); + +/** + * The shapes people actually upload. + * + * Every case here is a definition that arrived from outside the editors, which + * is the only way most definitions arrive. What is being asserted is not that + * the parser is lenient — it is that the three identity fields, the pages and + * the section a reader can see in the file are the ones the form shows. + */ +const COMPLETE = `--- +id: hiring-activity-assistant +name: Hiring Activity Assistant +description: Answer questions about recent hiring activity on a position. +type: board +ui: + - page: Positions + placement: grid-card + source: position.activity +--- + +# Hiring Activity Assistant +`; + +const NO_ID = `--- +name: Hiring Activity Assistant +description: Answer questions about recent hiring activity. +pages: + - positions +--- + +# Hiring Activity Assistant +`; + +const MULTI_PAGE = `--- +id: hiring-activity +name: Hiring Activity +description: Hiring activity across the workspace. +ui: + - page: Positions + placement: grid-card + source: candidates.activity + - page: Analytics + placement: panel + source: hires.performance +--- + +# Hiring Activity +`; + +/* Case 1 — a complete definition hydrates every field it declares. */ +{ + const f = fields.boardFieldsFromSource(COMPLETE); + record('upload: name hydrates', f.name === 'Hiring Activity Assistant', f.name || 'EMPTY'); + record('upload: id hydrates', f.id === 'hiring-activity-assistant', f.id || 'EMPTY'); + record( + 'upload: description hydrates', + f.description === 'Answer questions about recent hiring activity on a position.', + f.description || 'EMPTY' + ); + record('upload: pages come from the `ui:` entries', f.pages.join(',') === 'positions', f.pages.join(',') || 'EMPTY'); + record('upload: `grid-card` resolves to a real placement', f.placement === 'after-position-card', f.placement || 'EMPTY'); + record('upload: source hydrates', f.source === 'position.activity', f.source || 'EMPTY'); + record('upload: an undeclared type is inferred from the source', f.type === 'flow', f.type || 'EMPTY'); + record('upload: the definition validates as written', reg.validateSkillSource(COMPLETE) === null, + reg.validateSkillSource(COMPLETE) || 'valid'); + record('upload: it is classified as a Board skill', + fields.facetsFromSource(COMPLETE).join(',') === 'ui', fields.facetsFromSource(COMPLETE).join(',')); + + /* The same file read by the other editor's reader — one pipeline, two views. */ + const o = fields.owliverFieldsFromSource(COMPLETE); + record('upload: the Owliver reader hydrates the same identity', + o.id === f.id && o.name === f.name && o.description === f.description); +} + +/* Case 2 — no `id:`, so it is slugged from the name and never invented. */ +{ + const f = fields.boardFieldsFromSource(NO_ID); + record('upload: a missing id slugs the name', f.id === 'hiring-activity-assistant', f.id || 'EMPTY'); + record('upload: a missing id does not become the placeholder path', f.id !== 'custom'); + record('upload: an explicit id is never replaced by a generated one', + fields.boardFieldsFromSource(COMPLETE).id === 'hiring-activity-assistant'); + record('upload: a file with no frontmatter hydrates nothing', + fields.boardFieldsFromSource('# Just a heading\n').id === ''); +} + +/* Case 3 — several pages, and every entry survives a field edit. */ +{ + const f = fields.boardFieldsFromSource(MULTI_PAGE); + record('upload: every page in the list is reported', f.pages.join(',') === 'positions,analytics', f.pages.join(',')); + + const parsed = reg.parseSkill(MULTI_PAGE, { custom: true }); + record('upload: every entry becomes a section', + parsed.ui.positions.sections.length === 1 && parsed.ui.analytics.sections.length === 1); + record('upload: each entry keeps its own source', + parsed.ui.positions.sections[0].source === 'candidates.activity' + && parsed.ui.analytics.sections[0].source === 'hires.performance'); + record('upload: a multi-page definition validates', reg.validateSkillSource(MULTI_PAGE) === null, + reg.validateSkillSource(MULTI_PAGE) || 'valid'); + + /* The fields must not be able to flatten it. */ + record('upload: the section fields are read-only against a list', !fields.uiIsEditableFromFields(MULTI_PAGE)); + record('upload: a single-section definition stays field-editable', + fields.uiIsEditableFromFields(templates.uiSkillTemplate({ id: 'x', name: 'X', pages: ['positions'] }))); + + /* Renaming is identity, not structure: it must still work, and must not + touch either entry. */ + const renamedMulti = fields.patchFrontmatter(MULTI_PAGE, { name: 'Renamed Multi' }); + const after = reg.parseSkill(renamedMulti, { custom: true }); + record('upload: renaming a multi-page definition keeps both entries', + after.ui.positions?.sections.length === 1 && after.ui.analytics?.sections.length === 1, + Object.keys(after.ui).join(',')); + record('upload: ...and actually renames it', after.name === 'Renamed Multi', after.name); + record('upload: ...and leaves the `ui:` text byte-identical', + renamedMulti.slice(renamedMulti.indexOf('ui:')) === MULTI_PAGE.slice(MULTI_PAGE.indexOf('ui:'))); +} + +/* Files as they actually arrive: from Windows, from a download, from paste. */ +{ + const dirty = { + 'a byte-order mark': `${COMPLETE}`, + 'CRLF line endings': COMPLETE.replace(/\n/g, '\r\n'), + 'a BOM and CRLF': `${COMPLETE.replace(/\n/g, '\r\n')}`, + 'a blank line above the fence': `\n\n${COMPLETE}`, + 'trailing spaces on the fence': COMPLETE.replace(/^---$/gm, '--- '), + }; + for (const [what, md] of Object.entries(dirty)) { + const f = fields.boardFieldsFromSource(md); + record( + `upload: a file with ${what} still hydrates`, + f.name === 'Hiring Activity Assistant' && f.id === 'hiring-activity-assistant' + && f.pages.join(',') === 'positions', + `${f.name || 'EMPTY'} / ${f.id || 'EMPTY'} / ${f.pages.join(',') || 'EMPTY'}` + ); + record( + `upload: ...and the registry reads it the same way`, + reg.parseSkill(md, { custom: true }).name === 'Hiring Activity Assistant', + reg.parseSkill(md, { custom: true }).name + ); + record(`upload: ...and it validates`, reg.validateSkillSource(md) === null, + reg.validateSkillSource(md) || 'valid'); + } + + /* Normalising on the way in is what keeps `patchFrontmatter` safe: an + unrecognised fence would have it write a second one above the first. */ + const patchedDirty = fields.patchFrontmatter(fields.normalizeUpload(`${COMPLETE}`), { name: 'Clean' }); + record('upload: patching a normalised file writes one frontmatter block', + (patchedDirty.match(/^---$/gm) || []).length === 2, + `${(patchedDirty.match(/^---$/gm) || []).length} fences`); + record('upload: ...and it still parses', reg.parseSkill(patchedDirty, { custom: true }).name === 'Clean'); + + /* A file with nothing to read must be refused, not read as a blank skill. */ + record('upload: a file with no fence is not readable', !fields.isReadableDefinition('# Just prose\n')); + record('upload: a real definition is readable', fields.isReadableDefinition(COMPLETE)); +} + +/* Page names and placements as they are written in the product, not as the + vocabulary spells them internally. */ +record('upload: `Positions` resolves to the positions surface', surfaces.canonicalPage('Positions') === 'positions'); +record('upload: `Talent Pool` resolves to the talent-pool surface', surfaces.canonicalPage('Talent Pool') === 'talent-pool'); +record('upload: `panel` on Analytics resolves to a real placement', + surfaces.placementFor('analytics', 'panel') === 'after-header'); +record('upload: an alias never resolves onto a surface that lacks it', + surfaces.placementFor('analytics', 'grid-card') === null); +record('upload: a canonical placement still resolves to itself', + surfaces.placementFor('positions', 'after-position-card') === 'after-position-card'); + +/* Case 4 — an existing definition reopened for editing. */ +for (const skill of reg.SKILLS) { + const f = skill.facets?.includes('ui') + ? fields.boardFieldsFromSource(skill.markdown) + : fields.owliverFieldsFromSource(skill.markdown); + record( + `reopening \`${skill.id}\` loads its identity unchanged`, + f.id === skill.id && f.name === skill.name && f.description === skill.description + && f.pages.join(',') === skill.pages.join(','), + `${f.id} / ${f.name} / ${f.pages.join(', ')}` + ); +} + +/* Case 5 — a manual edit is not reverted by later synchronisation. */ +{ + /* The editor's own handler, in miniature: hydrate from the file, edit one + field, then edit an unrelated one. The first edit must survive the second. */ + let draft = fields.owliverFieldsFromSource(COMPLETE); + let src = COMPLETE; + const edit = (patch) => { + draft = { ...draft, ...patch }; + src = fields.patchFrontmatter(src, { + id: draft.id || undefined, + name: draft.name || undefined, + description: draft.description || undefined, + pages: draft.pages?.length && !fields.pagesAreDerived(src) ? draft.pages : undefined, + }); + }; + + edit({ name: 'My Own Name' }); + record('edit: a manual name reaches the artefact', + reg.parseSkill(src, { custom: true }).name === 'My Own Name'); + + edit({ description: 'My own description.' }); + record('edit: a later edit does not revert the earlier one', + reg.parseSkill(src, { custom: true }).name === 'My Own Name', + reg.parseSkill(src, { custom: true }).name); + record('edit: ...and applies itself', + reg.parseSkill(src, { custom: true }).description === 'My own description.'); + record('edit: ...and the `ui:` block is untouched throughout', + src.slice(src.indexOf('ui:')) === COMPLETE.slice(COMPLETE.indexOf('ui:'))); + + /* A second upload replaces the draft outright — it is a new source. */ + const rehydrated = fields.owliverFieldsFromSource(MULTI_PAGE); + record('edit: a derived `pages:` is never written back', + !src.includes('\npages:'), src.includes('\npages:') ? 'pages: was inserted' : 'not written'); + + record('edit: a declared `pages:` still patches normally', + reg.parseSkill( + fields.patchFrontmatter(NO_ID, { pages: ['analytics'] }), { custom: true } + ).pages.join(',') === 'analytics'); + + record('edit: an inherited trigger is not written into the file', + !src.includes('triggers:'), src.includes('triggers:') ? 'triggers: was materialised' : 'not written'); + record('edit: a declared trigger is still read into the fields', + fields.owliverFieldsFromSource( + reg.SKILLS.find((x) => x.id === 'hiring-activity-assistant').markdown + ).triggers.includes('hiring activity')); + record('edit: a definition with no triggers reads none', + fields.owliverFieldsFromSource(COMPLETE).triggers.length === 0, + JSON.stringify(fields.owliverFieldsFromSource(COMPLETE).triggers)); + + record('edit: a second upload hydrates from the new file', + rehydrated.id === 'hiring-activity' && rehydrated.name === 'Hiring Activity', + `${rehydrated.id} / ${rehydrated.name}`); +} + await server.close(); -/* ── 6. Production bundle ─────────────────────────────────────────────────── */ +/* ── 8. Production bundle ─────────────────────────────────────────────────── */ if (process.argv.includes('--dist')) { console.log('\n── Production bundle ──'); const dir = join(ROOT, 'dist/assets'); diff --git a/skill-examples/board-invalid-context.md b/skill-examples/board-invalid-context.md new file mode 100644 index 0000000..3475ff8 --- /dev/null +++ b/skill-examples/board-invalid-context.md @@ -0,0 +1,26 @@ +--- +id: board-invalid-context +name: Board (invalid context) +description: Negative test — a position-scoped source on a placement that supplies no position. +pages: + - positions +status: active +ui: + type: card + placement: after-position-list-summary + title: Board + source: position.activity + periods: + - today + - last-7-days + - previous-month +--- + +# Board (invalid context) + +## Purpose + +Deliberately wrong, and kept so the refusal stays tested. `position.activity` +needs one position to read; `after-position-list-summary` renders once above the +grid with no position in context. Saving this must be refused with a message that +names both halves of the mismatch. diff --git a/skill-examples/board-page-level.md b/skill-examples/board-page-level.md new file mode 100644 index 0000000..70ee7c1 --- /dev/null +++ b/skill-examples/board-page-level.md @@ -0,0 +1,31 @@ +--- +id: board +name: Board +description: Helps Owliver understand, analyze, and act on the current task board. +pages: + - positions +status: active +ui: + type: card + placement: after-position-list-summary + title: Board + source: candidates.activity + periods: + - today + - last-7-days + - previous-month +--- + +# Board + +## Purpose + +Helps Owliver understand the current task board by summarizing relevant work activity, identifying items that need attention, and explaining the current status of tasks shown on the board. + +## Capabilities + +- Summarize relevant activity from the current board. +- Identify tasks or work items that need attention. +- Explain the current status of tasks and work items. +- Highlight recent activity and meaningful changes. +- Help Owliver analyze board activity using the available board data. diff --git a/skill-examples/board-position-card.md b/skill-examples/board-position-card.md new file mode 100644 index 0000000..749942e --- /dev/null +++ b/skill-examples/board-position-card.md @@ -0,0 +1,31 @@ +--- +id: board-position-card +name: Board (per position) +description: Control test — the same Board card, rendered inside every position card. +pages: + - positions +status: active +ui: + type: card + placement: grid-card + title: Board + source: position.activity + periods: + - today + - last-7-days + - previous-month +--- + +# Board (per position) + +## Purpose + +The position-aware control for the page-level Board card. `grid-card` resolves to +`after-position-card`, which renders inside each position card and supplies that +position as context — so `position.activity` reads and the card is drawn once per +role rather than once for the page. + +## Capabilities + +- Summarize activity for the position the card belongs to. +- Show that activity across today, the last 7 days and the previous month. diff --git a/skill-examples/board-ui-test.md b/skill-examples/board-ui-test.md new file mode 100644 index 0000000..150e53e --- /dev/null +++ b/skill-examples/board-ui-test.md @@ -0,0 +1,40 @@ +--- +id: board-ui-test +name: Board +description: Renders the Board card once above the position list on the Positions page. +pages: + - positions +status: active +ui: + type: card + placement: after-position-list-summary + title: Board + source: candidates.activity + periods: + - today + - last-7-days + - previous-month +--- + +# Board + +## Purpose + +The page half of the Board, and only that half. One card, above the position +grid, reading activity across the workspace. + +`after-position-list-summary` renders once for the page and supplies no +position — `surfaces.js` records that placement as providing nothing — so the +card reads `candidates.activity`, which declares `context: null` and counts +applications across the workspace over the periods named here. +`position.activity` is refused at this placement by `unresolvableSections`, and +correctly: it needs one position, and this placement has none to give. + +This definition declares no `owliver:` block, so it contributes no capability, +no suggestion chip and no conversational answer. It is a page extension and +nothing else. + +## Capabilities + +- Show workspace application activity above the position list. +- Compare today, the last 7 days and the previous month in one card. diff --git a/skill-examples/owliver-conversation-test.md b/skill-examples/owliver-conversation-test.md new file mode 100644 index 0000000..77053a6 --- /dev/null +++ b/skill-examples/owliver-conversation-test.md @@ -0,0 +1,63 @@ +--- +id: owliver-conversation-test +name: Workspace Activity Assistant +description: Answer questions about applications across the workspace and the roles still open. +pages: + - positions +status: active +triggers: + - workspace applications + - applications across the workspace + - roles still open + - roles needing attention +owliver: + enabled: true + # Every suggestion names the capability it asks for, so a chip and an answer + # are one-to-one rather than both falling through to the first declared + # capability. + suggestions: + - label: Summarize workspace applications + capability: summary + - label: List the roles still open + capability: list + capabilities: + - summary + - list + responses: + summary: + title: Workspace Applications + source: candidates.activity + periods: + - today + - last-7-days + - previous-month + list: + title: Roles Still Open + source: positions.demand + limit: 5 +--- + +# Workspace Activity Assistant + +## Purpose + +The panel half, and only that half. No `ui:` block, so nothing is drawn on the +Positions page and no card appears above or inside the grid. + +Each response names its own `source:` directly, which is what a definition +without a `ui:` section must do — `normalizeSkillOwliver` inherits a source from +the first `ui:` section only when one exists, and reports +`owliver.responses.: a response needs a source, or a ui: section to +read from` when neither is present. + +Both sources declare `context: null`, so neither answer asks which position is +meant: `resolveEntity` returns immediately and no +"This section needs a position to read" is possible. The shapes match what each +source offers — `summary` is prose and is exempt from the shape check, and +`list` is a shape `positions.demand` declares. + +## Capabilities + +- Summarize applications across the workspace over today, the last 7 days and + the previous month. +- List the open positions and how many applicants each has. diff --git a/src/api/base44Client.js b/src/api/base44Client.js index 89ec7b1..6d38611 100644 --- a/src/api/base44Client.js +++ b/src/api/base44Client.js @@ -43,6 +43,37 @@ function loadUser() { let currentUser = loadUser(); +/** + * Writes the session user, and says whether it actually landed. + * + * The old version was `try { setItem } catch {}` — a swallowed + * `QuotaExceededError` or a private-browsing refusal, and the caller was handed + * a user object indistinguishable from a successful write. For preferences that + * is invisible; for `customSkills`, which is where every account-authored skill + * definition lives, it is the whole "I saved it and it was gone" report: the + * toast said added, the list showed it, the reload did not. + * + * The read-back matters as much as the catch. A write can be accepted and then + * evicted, and a serialisation can land truncated; comparing what came back + * with what went in is the only way to know the record is really there. + */ +function persistUser() { + const payload = JSON.stringify(currentUser); + try { + localStorage.setItem(SESSION_KEY, payload); + } catch (error) { + return { persisted: false, error }; + } + try { + if (localStorage.getItem(SESSION_KEY) !== payload) { + return { persisted: false, error: new Error('The session record did not survive the write.') }; + } + } catch (error) { + return { persisted: false, error }; + } + return { persisted: true, error: null }; +} + const auth = { /** The demo is always signed in as the seeded employer/admin user. */ async me() { @@ -51,11 +82,7 @@ const auth = { async updateMe(patch) { currentUser = { ...currentUser, ...patch }; - try { - localStorage.setItem(SESSION_KEY, JSON.stringify(currentUser)); - } catch { - // Non-persistent session is fine. - } + persistUser(); return { ...currentUser }; }, @@ -72,9 +99,18 @@ const auth = { return { ...DEMO_USER.preferences, ...(currentUser.preferences || {}) }; }, - /** Merges into the stored preferences and persists with the rest of the user. */ + /** + * Merges into the stored preferences and persists with the rest of the user. + * + * Returns the write's outcome alongside the record, rather than the record + * alone. Preferences are where account-authored skills live, so "did this + * survive the reload" is a question the caller has to be able to answer — + * see `persistUser`. + */ async updatePreferences(patch) { - return auth.updateMe({ preferences: { ...auth.preferences(), ...patch } }); + currentUser = { ...currentUser, preferences: { ...auth.preferences(), ...patch } }; + const write = persistUser(); + return { user: { ...currentUser }, ...write }; }, isAuthenticated() { diff --git a/src/components/ai-assistant/AssistantMessage.jsx b/src/components/ai-assistant/AssistantMessage.jsx index b85a549..a3119a3 100644 --- a/src/components/ai-assistant/AssistantMessage.jsx +++ b/src/components/ai-assistant/AssistantMessage.jsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Sparkles } from 'lucide-react'; import { cn } from '@/lib/utils'; import { ResponseDocument } from './ResponseBlocks'; +import OwliverAvatar from '@/components/krow/OwliverAvatar'; /** * The thinking state, shown between sending and the first block arriving. @@ -12,9 +13,7 @@ import { ResponseDocument } from './ResponseBlocks'; export function ThinkingIndicator() { return (
- - + {[0, 1, 2].map((i) => (
- - + Owliver
diff --git a/src/components/ai-assistant/AssistantPanel.jsx b/src/components/ai-assistant/AssistantPanel.jsx index defece7..a146393 100644 --- a/src/components/ai-assistant/AssistantPanel.jsx +++ b/src/components/ai-assistant/AssistantPanel.jsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'; import KrowAssistant from './KrowAssistant'; import { useAssistantPanel } from './AssistantPanelContext'; import { ResizeDivider } from './ResizeDivider'; +import OwliverAvatar from '@/components/krow/OwliverAvatar'; const EXPANDED_WIDTH = 620; /** @@ -68,9 +69,7 @@ function CollapsedTrigger({ page, onRestore }) { hover:border-krow-blue/40 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50" > - - KROW Logo - + Owliver