diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx
index ccb7c28..a9bcb8c 100644
--- a/src/components/ai-assistant/KrowAssistant.jsx
+++ b/src/components/ai-assistant/KrowAssistant.jsx
@@ -5,7 +5,7 @@ import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import { KROW_LOGO_URL } from '@/assets/brand';
-import { useAssistantFacts, useConversation } from './useAssistant';
+import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
import { buildIntro, buildPrompts } from './dynamic';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
@@ -43,7 +43,8 @@ export default function KrowAssistant({
onExpand,
onRestore,
}) {
- const facts = useAssistantFacts({ needsWorkerProfile: context.needsWorkerProfile });
+ const facts = useAssistantFacts();
+ const userName = useCurrentUserName();
const { messages, pending, error, busy, send, stop, reset } = useConversation({
contextId: context.id,
facts,
@@ -56,8 +57,8 @@ export default function KrowAssistant({
/* Greeting and suggestions come from live data, so they recompute only when
the data or the page actually changes. */
const intro = React.useMemo(
- () => buildIntro(context.id, facts, facts.profile?.full_name),
- [context.id, facts]
+ () => buildIntro(context.id, facts, userName),
+ [context.id, facts, userName]
);
const prompts = React.useMemo(() => buildPrompts(context.id, facts), [context.id, facts]);
diff --git a/src/components/ai-assistant/capabilities/talent.js b/src/components/ai-assistant/capabilities/talent.js
deleted file mode 100644
index 7f47032..0000000
--- a/src/components/ai-assistant/capabilities/talent.js
+++ /dev/null
@@ -1,204 +0,0 @@
-import { BookOpen, Compass, FileText } from 'lucide-react';
-import { actions, answer, badges, doc, heading, insights, kpis, meters, note, status, table, text } from '../blocks';
-import { plural } from '../insights';
-import { getScoreBand, toFICO } from '@/lib/talentHome';
-
-/**
- * Talent capabilities — My Portal.
- *
- * The register shifts here: an employer wants a read on a pipeline, a worker
- * wants to know what to do next to earn more. Answers name the specific course
- * or step, because "improve your skills" helps nobody.
- */
-
-const scoreOf = (p) => Number(p?.krow_score) || 0;
-
-const NO_PROFILE = 'I do not have your profile yet. Once it exists I can give you specifics instead of generalities.';
-
-const resumeReview = (f) => {
- const p = f.profile;
- if (!p) return answer(NO_PROFILE);
-
- const fields = [
- { label: 'Photo', ok: Boolean(p.selfie_url), note: 'The KROW ID replaces a résumé — a profile with a face gets opened more.' },
- { label: 'Availability', ok: (p.availability || []).length > 0, note: 'Employers filter on this before they read anything else.' },
- { label: 'Certifications', ok: (p.certifications || []).length > 0, note: 'A Food Handler Card is the cheapest credential in this industry.' },
- { label: 'Skills', ok: (p.skills || []).length > 0, note: 'Without these you do not appear in skill searches.' },
- { label: 'Work history', ok: (p.experience || []).length > 0, note: 'Even one role gives employers something to anchor on.' },
- { label: 'Career goals', ok: Boolean(p.career_goals), note: 'Lets matching put you in front of the right roles, not every role.' },
- { label: 'Verified evidence', ok: (p.capabilities || []).length > 0, note: 'The only part of your profile an employer does not have to take on trust.' },
- ];
-
- const complete = fields.filter((x) => x.ok).length;
-
- return doc(
- heading(`Your profile is ${p.profile_completion || 0}% complete`, `${complete} of ${fields.length} things employers look for`),
- status(fields.map((x) => ({ label: x.label, ok: x.ok, note: x.ok ? undefined : x.note }))),
- (p.certifications || []).length && [
- heading('Credentials on file'),
- badges(p.certifications.map((label) => ({ label, tone: 'success' }))),
- ],
- text(
- complete === fields.length
- ? 'Nothing is missing. From here, more verified evidence is what moves you — not more fields.'
- : 'If you fix two things, fix availability and certifications. Those are the two employers filter on first.'
- ),
- note(
- (p.capabilities || []).length
- ? 'Your verified capabilities came with evidence attached, not a checkbox. That is why they carry weight.'
- : 'Nothing is verified yet. One completed challenge is worth more than ten claimed skills.'
- )
- );
-};
-
-const careerGuidance = (f) => {
- const p = f.profile;
- if (!p) return answer(NO_PROFILE);
-
- const score = scoreOf(p);
- const band = getScoreBand(score);
- const target = score >= 90 ? null : score >= 75 ? 90 : score >= 60 ? 75 : 60;
-
- return doc(
- heading(`${toFICO(score)} — ${band.label}`, 'Career score, on a 300–850 scale'),
- kpis([
- { label: 'Reliability', value: p.reliability_score || 0, tone: (p.reliability_score || 0) >= 70 ? 'success' : 'warning' },
- { label: 'Attendance', value: p.attendance_score ?? 0, tone: (p.attendance_score ?? 0) >= 90 ? 'success' : undefined },
- { label: 'Shifts worked', value: p.shifts_completed || 0, tone: (p.shifts_completed || 0) === 0 ? 'warning' : undefined },
- { label: 'Verified skills', value: (p.capabilities || []).length },
- ]),
- heading("What's driving it"),
- meters([
- { label: 'Reliability', value: p.reliability_score || 0, weight: 25 },
- { label: 'Attendance', value: p.attendance_score ?? 0, weight: 20 },
- { label: 'Communication', value: p.performance_score || 0, weight: 15 },
- { label: 'Leadership', value: p.leadership_potential || 0, weight: 15 },
- { label: 'Learning', value: Math.min(100, (p.completed_courses || []).length * 8 + (p.xp || 0) / 10), weight: 15 },
- { label: 'Problem solving', value: p.ai_interview_score || 0, weight: 10 },
- ]),
- insights([
- target
- ? {
- tone: 'info',
- title: `To reach ${target}`,
- body: 'Verified work is the fastest lever. Courses move the score; verified shifts move it more, because employers weight what someone else confirmed over what you claimed.',
- }
- : {
- tone: 'success',
- title: 'You are in the top band',
- body: 'From here it is about holding it — the score decays if shifts stop.',
- },
- (p.shifts_completed || 0) === 0 && {
- tone: 'warning',
- title: 'Work one shift',
- body: 'Attendance, ratings and reliability all need a first data point before anything else compounds.',
- },
- p.desired_position
- ? {
- tone: score >= 75 ? 'success' : 'info',
- title: `Aiming for ${p.desired_position}`,
- body: score >= 75
- ? 'You are already scoring in the range employers shortlist for that.'
- : 'The gap to that role is verification, not ambition.',
- }
- : {
- tone: 'info',
- title: 'No target role set',
- body: 'Setting one lets me tell you which courses matter and which are noise.',
- },
- ])
- );
-};
-
-const learningRecommendations = (f) => {
- const p = f.profile;
- if (!p) return answer(NO_PROFILE);
-
- const completedIds = new Set((p.completed_courses || []).map((c) => c.course_id));
- const done = (p.completed_courses || []).length;
- const shifts = p.shifts_completed || 0;
- const available = (f.courses || []).filter((c) => c.status === 'active' && !completedIds.has(c.id));
-
- if (!available.length) {
- return doc(
- heading('You have completed everything available'),
- kpis([
- { label: 'Courses', value: done, tone: 'success' },
- { label: 'Badges', value: (p.earned_badges || []).length, tone: 'success' },
- { label: 'XP', value: p.xp || 0 },
- { label: 'Verified', value: (p.capabilities || []).length },
- ]),
- text('The next lever is verified shifts — that is what employers weight most.')
- );
- }
-
- const ranked = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0));
-
- return doc(
- heading('What to learn next', 'Ranked by what moves your score most'),
- table(
- [
- { key: 'title', label: 'Course' },
- { key: 'minutes', label: 'Time', align: 'right' },
- { key: 'xp', label: 'XP', align: 'right' },
- ],
- ranked.slice(0, 4).map((c) => {
- const gate = c.unlock_requirements?.min_shifts || 0;
- const locked = gate > shifts;
- return {
- title: c.title,
- minutes: `${c.estimated_minutes}m`,
- xp: locked
- ? { badge: `${gate} shifts`, tone: 'warning' }
- : { value: `+${c.xp}`, tone: 'success' },
- };
- })
- ),
- heading('Where you stand'),
- kpis([
- { label: 'Courses done', value: done },
- { label: 'Badges', value: (p.earned_badges || []).length },
- { label: 'XP', value: p.xp || 0 },
- { label: 'Verified skills', value: (p.capabilities || []).length },
- ]),
- actions([
- {
- title: done === 0 ? `Start with ${ranked[ranked.length - 1].title}` : `Take ${ranked[0].title}`,
- body: done === 0
- ? 'The point of the first course is not the XP — it is getting one verified capability on the board.'
- : done < 3
- ? `Three completions is where the score starts moving noticeably. You are at ${done}.`
- : 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.',
- },
- shifts < 5 && {
- title: `Log ${plural(5 - shifts, 'more shift')}`,
- body: `Advanced challenges stay locked until 5 shifts — you have ${shifts}. That gate is deliberate: leadership evidence from someone who has never run a floor is not evidence.`,
- },
- ]),
- note('Each course ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored.')
- );
-};
-
-/* ── Free-text responder ────────────────────────────────────────────────── */
-
-const has = (q, ...words) => words.some((w) => q.includes(w));
-
-export const respondTalent = (question, f) => {
- const q = question.toLowerCase();
- if (has(q, 'resume', 'résumé', 'cv', 'profile', 'improve')) return resumeReview(f);
- if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge', 'path')) return learningRecommendations(f);
- if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next', 'started')) return careerGuidance(f);
-
- const p = f.profile;
- return answer(
- p
- ? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?`
- : NO_PROFILE
- );
-};
-
-export const TALENT_CAPABILITIES = [
- { id: 'resume-review', label: 'Resume Review', icon: FileText, run: resumeReview },
- { id: 'career-guidance', label: 'Career Guidance', icon: Compass, run: careerGuidance },
- { id: 'learning-recommendations', label: 'Learning Recommendations', icon: BookOpen, run: learningRecommendations },
-];
diff --git a/src/components/ai-assistant/contexts.js b/src/components/ai-assistant/contexts.js
index c5ced82..7ba7164 100644
--- a/src/components/ai-assistant/contexts.js
+++ b/src/components/ai-assistant/contexts.js
@@ -6,7 +6,6 @@ import {
ACTIVITY_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, CONTROL_CENTER_CAPABILITIES,
respondActivity, respondAdminCandidates, respondControlCenter,
} from './capabilities/admin';
-import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent';
/**
* Page contexts for the Krow AI Assistant.
@@ -16,8 +15,8 @@ import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent';
* live data in `dynamic.js`, not written here. A context only declares what this
* page's assistant can *do*: its capabilities and its free-text responder.
*
- * `needsWorkerProfile` gates the one query with a side effect, so employer and
- * admin sessions never touch worker-profile records.
+ * Krow AI serves Employer and Admin. The talent portal has Owliver, a separate
+ * product with its own UI and workflow — nothing here targets it.
*/
export const ASSISTANT_CONTEXTS = {
'employer.overview': {
@@ -56,13 +55,6 @@ export const ASSISTANT_CONTEXTS = {
capabilities: ACTIVITY_CAPABILITIES,
respond: respondActivity,
},
- 'talent.portal': {
- id: 'talent.portal',
- page: 'My Portal',
- capabilities: TALENT_CAPABILITIES,
- respond: respondTalent,
- needsWorkerProfile: true,
- },
};
export const getContext = (id) => ASSISTANT_CONTEXTS[id] ?? null;
diff --git a/src/components/ai-assistant/dynamic.js b/src/components/ai-assistant/dynamic.js
index 9773c37..fd13011 100644
--- a/src/components/ai-assistant/dynamic.js
+++ b/src/components/ai-assistant/dynamic.js
@@ -98,14 +98,6 @@ const GREETINGS = {
return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week.`;
},
- 'talent.portal': (f) => {
- const p = f.profile;
- if (!p) return 'Let us get your profile started.';
- const completion = p.profile_completion || 0;
- if (completion >= 90) return 'Your profile is nearly complete — nice work.';
- if (completion > 0) return `You are ${completion}% of the way through your profile.`;
- return 'Your profile is empty. A few fields will change what employers see.';
- },
};
/** Title lines. First names read better than a formal greeting in a small panel. */
@@ -116,7 +108,6 @@ const TITLES = {
'admin.controlCenter': (f) => `Platform check, ${timeOfDay(f.today)}`,
'admin.candidates': () => 'Talent analysis',
'admin.activity': () => 'Activity review',
- 'talent.portal': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
};
/** Descriptions state what this page's assistant can actually do with the data. */
@@ -128,7 +119,6 @@ const DESCRIPTIONS = {
'admin.controlCenter': () => 'Health checks, data integrity and what needs intervention.',
'admin.candidates': () => 'Talent supply and risk across the candidate pool.',
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
- 'talent.portal': () => 'Your score, your profile, and what to learn next.',
};
/** Placeholders phrased as things this page's user would actually type. */
@@ -151,7 +141,6 @@ const PLACEHOLDERS = {
'admin.controlCenter': () => ['Show unusual activity…', 'Summarize platform usage…'],
'admin.candidates': () => ['Where is the risk in the pool?', 'Summarize recruitment trends…'],
'admin.activity': () => ['Show unusual activity…', 'Summarize platform usage…'],
- 'talent.portal': () => ['Improve my profile…', 'Recommend learning paths…'],
};
/* ── Suggested prompts ──────────────────────────────────────────────────── */
@@ -229,16 +218,6 @@ const PROMPTS = {
: null,
].filter(Boolean),
- 'talent.portal': (f) => {
- const p = f.profile;
- return [
- { label: 'Improve my profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' },
- { label: 'Recommend learning paths', prompt: 'What should I learn next?', capability: 'learning-recommendations' },
- p && (p.krow_score || 0) > 0
- ? { label: 'How do I score higher?', prompt: 'How do I raise my career score?', capability: 'career-guidance' }
- : { label: 'How do I get started?', prompt: 'How do I raise my career score?', capability: 'career-guidance' },
- ];
- },
};
/* ── Public API ─────────────────────────────────────────────────────────── */
diff --git a/src/components/ai-assistant/placement.js b/src/components/ai-assistant/placement.js
index 47b1857..cd97284 100644
--- a/src/components/ai-assistant/placement.js
+++ b/src/components/ai-assistant/placement.js
@@ -22,9 +22,6 @@ const PLACEMENT = {
'/candidates': 'admin.candidates', // Candidates Analysis
'/tracking': 'admin.activity', // Activity
},
- talent: {
- '/employee': 'talent.portal', // My Portal
- },
};
/**
@@ -34,13 +31,16 @@ const PLACEMENT = {
* Employer: Positions, Position Detail, Create Position, Hired History,
* Talent Pool, Profile.
* Admin: everything except the three above.
- * Talent: everything except My Portal.
+ * Talent: every page. The talent portal has Owliver — a separate product with
+ * its own voice experience, branding and workflow. Krow AI does not
+ * appear there and must not replace it.
*/
export const EXCLUDED_ROUTES = [
'/positions', '/positions/new', '/positions/:id',
'/hired', '/talent-pool', '/profile', '/apply',
'/university', '/university/:id', '/me', '/identity',
'/owliver', '/design-system',
+ '/employee', // Talent portal — Owliver's, not Krow AI's.
];
/** Returns the context for a role and path, or `null` to render no assistant. */
diff --git a/src/components/ai-assistant/useAssistant.js b/src/components/ai-assistant/useAssistant.js
index 38bc6b6..0b51c47 100644
--- a/src/components/ai-assistant/useAssistant.js
+++ b/src/components/ai-assistant/useAssistant.js
@@ -1,7 +1,9 @@
import * as React from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { base44 } from '@/api/base44Client';
import {
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
- useUserActivity, useWorkerProfile, useWorkerProfiles,
+ useUserActivity, useWorkerProfiles,
} from '@/lib/krowHooks';
import { buildFacts } from './insights';
import { createAssistantProvider } from './provider';
@@ -14,7 +16,7 @@ const provider = createAssistantProvider();
* costs no extra requests and cannot be looking at a different snapshot than the
* card beside it.
*/
-export function useAssistantFacts({ needsWorkerProfile = false } = {}) {
+export function useAssistantFacts() {
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
@@ -23,19 +25,30 @@ export function useAssistantFacts({ needsWorkerProfile = false } = {}) {
const { data: activity = [] } = useUserActivity();
const { data: courses = [] } = useCourses();
- // Gated: this query creates the record if absent, so it must not run for
- // employer or admin sessions.
- const { data: profile = null } = useWorkerProfile({ enabled: needsWorkerProfile });
-
+ // Krow AI serves Employer and Admin only, so it never reads the signed-in
+ // worker's own profile — that query also creates the record if absent, and it
+ // belongs to the talent portal, not here.
return React.useMemo(
- () => buildFacts({
- applications, postings, interviews, staff, profiles, activity, courses,
- profile: needsWorkerProfile ? profile : null,
- }),
- [applications, postings, interviews, staff, profiles, activity, courses, profile, needsWorkerProfile]
+ () => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses }),
+ [applications, postings, interviews, staff, profiles, activity, courses]
);
}
+/**
+ * The signed-in user's first name, for personalizing the greeting.
+ *
+ * Shares the `['user']` query key with the rest of the app, so this is a cache
+ * read rather than another request.
+ */
+export function useCurrentUserName() {
+ const { data } = useQuery({
+ queryKey: ['user'],
+ queryFn: () => base44.auth.me().catch(() => null),
+ staleTime: Infinity,
+ });
+ return data?.full_name || '';
+}
+
/**
* Conversation state for one page context.
*
diff --git a/src/layouts/Layout.jsx b/src/layouts/Layout.jsx
index c7c3285..4b9f95f 100644
--- a/src/layouts/Layout.jsx
+++ b/src/layouts/Layout.jsx
@@ -10,6 +10,7 @@ import { logActivity } from '@/lib/userTracking';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant';
+import OwliverChatBubble from '@/components/krow/OwliverChatBubble';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
import KrowIdPreview from '@/components/krow/KrowIdPreview';
@@ -52,6 +53,9 @@ export default function Layout() {
const [menuOpen, setMenuOpen] = useState(false);
const [user, setUser] = useState(null);
const [activeRole, setActiveRole] = useState(() => localStorage.getItem('activeRole') || 'employer');
+ // Owliver's own open state. Owliver is a separate product with its own UI and
+ // voice workflow; it is not routed through the Krow AI panel.
+ const [owliverOpen, setOwliverOpen] = useState(false);
useEffect(() => {
base44.auth.me().then(setUser).catch(() => {});
@@ -89,13 +93,13 @@ export default function Layout() {
navigate(ROLE_HOME[role]);
};
- // Talent can be sent to the Owliver profile-builder conversation from
- // anywhere via this event (the portal's own CTA uses it).
+ // Anything in the talent portal can open Owliver through this event — the
+ // portal hero's own CTA uses it.
useEffect(() => {
- const openBuilder = () => navigate('/owliver');
- window.addEventListener('krow:open-owliver', openBuilder);
- return () => window.removeEventListener('krow:open-owliver', openBuilder);
- }, [navigate]);
+ const openOwliver = () => setOwliverOpen(true);
+ window.addEventListener('krow:open-owliver', openOwliver);
+ return () => window.removeEventListener('krow:open-owliver', openOwliver);
+ }, []);
const navItems = ROLE_NAV[activeRole];
const isTalent = activeRole === 'talent';
@@ -190,7 +194,7 @@ export default function Layout() {
navigate(isTalent ? '/owliver' : '/identity')}
+ onClick={() => (isTalent ? window.dispatchEvent(new CustomEvent('krow:open-owliver')) : navigate('/identity'))}
className="cursor-pointer"
>
@@ -314,7 +318,7 @@ export default function Layout() {
))}