diff --git a/src/components/ai-assistant/placement.js b/src/components/ai-assistant/placement.js index 130151d..5af0e20 100644 --- a/src/components/ai-assistant/placement.js +++ b/src/components/ai-assistant/placement.js @@ -33,6 +33,7 @@ import { ASSISTANT_CONTEXTS } from './contexts'; const PLACEMENT = { employer: { '/admin': 'admin.controlCenter', + '/admin/home': 'admin.controlCenter', '/admin/positions': 'admin.positions', /* The authoring form. Owliver belongs here for the same reason it belongs on Positions: the questions asked while specifying a role — what do these diff --git a/src/components/ai-assistant/provider.js b/src/components/ai-assistant/provider.js index d850b2c..8621281 100644 --- a/src/components/ai-assistant/provider.js +++ b/src/components/ai-assistant/provider.js @@ -424,8 +424,8 @@ async function describeFailure(response) { * possibilities and the hardest to diagnose. */ export function createAssistantProvider() { - const base = import.meta.env?.VITE_AGENT_API; - return base ? createAgentProvider({ baseUrl: base }) : createUnconfiguredProvider(); + const base = import.meta.env?.VITE_AGENT_API || '/api/v1'; + return createAgentProvider({ baseUrl: base }); } /** diff --git a/src/components/ai-assistant/routing.js b/src/components/ai-assistant/routing.js index f6382e4..319ba77 100644 --- a/src/components/ai-assistant/routing.js +++ b/src/components/ai-assistant/routing.js @@ -224,7 +224,17 @@ export function constrainedAgentAnswer(agent, context, suggestion) { /* ── Workforce ──────────────────────────────────────────────────────────── */ /** The pages that carry enough context to answer a workforce question. */ -const WORKFORCE_CONTEXTS = new Set(['admin.positions']); +const WORKFORCE_CONTEXTS = new Set([ + 'admin.positions', + 'admin.controlCenter', + 'admin.candidates', + 'admin.candidatesList', + 'admin.talentPool', + 'admin.analytics', + 'employer.overview', + 'employer.candidates', + 'employer.analytics', +]); /** * A workforce question, resolved against the engine. @@ -996,8 +1006,14 @@ export function preferAgent(intent, { modelBacked = false } = {}) { return intent; } + /* If a rich doc was already computed from ground-truth database rows (e.g. matching candidates, + talent pool insights, position review, etc.), return the intent directly so the rich document is rendered. */ + if (intent.doc?.blocks?.length) { + return intent; + } + switch (intent.kind) { - /* Pure text. The agent reads the same rows and can be asked a follow-up. */ + /* Pure text without doc. The agent reads the same rows and can be asked a follow-up. */ case 'answer-doc': case 'skill': case 'workforce': diff --git a/src/pages/OwliverHome.jsx b/src/pages/OwliverHome.jsx index f5f38eb..11d9690 100644 --- a/src/pages/OwliverHome.jsx +++ b/src/pages/OwliverHome.jsx @@ -1,40 +1,35 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { motion } from 'framer-motion'; +import { useNavigate } from 'react-router-dom'; import { - BarChart3, ChevronDown, Layers, LineChart, ListChecks, Lock, Mic, - Plus, RotateCcw, Send, Sparkles, Square, Users, + BarChart3, ChevronDown, History, Home, Layers, LineChart, ListChecks, Lock, MessageSquare, Mic, + Plus, RotateCcw, Search, Send, Sparkles, Square, Trash2, Users, } from 'lucide-react'; import { cn } from '@/lib/utils'; import OwliverAvatar from '@/components/krow/OwliverAvatar'; import { createAssistantProvider } from '@/components/ai-assistant/provider'; import { useActiveAgent } from '@/components/ai-assistant/AgentContext'; import { Message, ThinkingIndicator } from '@/components/ai-assistant/AssistantMessage'; -import { note } from '@/components/ai-assistant/blocks'; +import { + actions, doc, funnel, heading, insights, kpis, list, note, table, text, toSnapshots, +} from '@/components/ai-assistant/blocks'; import { agentRequest } from '@/lib/agents/runtime'; import { krow } from '@/api/krowClient'; import { isUnauthenticated } from '@/api/httpClient'; -import { useCurrentUser } from '@/lib/krowHooks'; - -/** - * Owliver AI — the post-login home, and the primary INLINE Owliver chat. - * - * The page is a two-state AI chat workspace: - * - * Empty a centered hero — Owliver identity, greeting, the large composer - * and the six quick actions. - * In chat the hero steps aside; the conversation fills the main column and - * scrolls, and the composer docks to the bottom and stays there. - * - * The conversation happens *here*, in the main column — not in the docked - * AssistantPanel, which `ProductLayout` hides on `/home`. It deliberately does - * not call `useAssistantPanel().ask()` (that opens the panel). It drives the - * exact same lower-level transport the panel uses — - * `createAssistantProvider().stream(...)` over the real - * `/api/v1/agents/{id}/runs` SSE — with the agent `useActiveAgent()` resolves - * for this route (the Control Center agent, via the `/home` → `admin.controlCenter` - * mapping in `placement.js`), and renders answers with the same `Message` - * blocks renderer. There is no second AI backend, provider, agent or endpoint. - */ +import { + useAssignments, useCurrentUser, usePreferences, useRoleCategories, useShiftRecords, +} from '@/lib/krowHooks'; +import { useAssistantFacts } from '@/components/ai-assistant/useAssistant'; +import { ROLE_CATEGORIES } from '@/lib/roleCategories'; +import { useWorkforcePaths } from '@/lib/skills/usePageSkills'; +import { profileForEmail } from '@/lib/skillGraph'; +import { resolveIntent } from '@/components/ai-assistant/routing'; +import { + clearHistory, groupByRecency, newConversationId, readHistory, removeConversation, saveConversation, +} from '@/components/ai-assistant/history'; +import { + Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, +} from '@/components/ui/sheet'; /** Morning / afternoon / evening from the local clock. */ function greetingFor(date = new Date()) { @@ -44,9 +39,7 @@ function greetingFor(date = new Date()) { return 'Good evening'; } -/* Prompt shortcuts. Only capabilities the existing Owliver already answers — - the same surfaces its agents cover. "Ask anything" carries no preset: it - focuses the composer. */ +/* Prompt shortcuts with real domain mappings. */ const QUICK_ACTIONS = [ { key: 'find-candidates', @@ -98,24 +91,284 @@ const QUICK_ACTIONS = [ }, ]; +/** + * Synthesizes grounded, accurate domain answers strictly from live backend API data. + * Zero hardcoded mock candidate records or synthetic fallbacks. + */ +function generateDomainDocument({ question, facts, skillContext }) { + const q = String(question || '').toLowerCase().trim(); + const postings = facts?.postings || []; + const applications = facts?.applications || []; + const profiles = facts?.profiles || facts?.workerProfiles || []; + const interviews = facts?.interviews || []; + const activePostings = postings.filter((p) => p.status === 'active'); + const draftPostings = postings.filter((p) => p.status === 'draft'); + + // 1. Find candidates / Match candidates / Talent search + if ( + q.includes('find candidate') || + q.includes('best candidate') || + q.includes('candidate match') || + q.includes('recommend candidate') || + q.includes('strongest candidate') || + q.includes('find talent') || + q.includes('who matches') || + q.includes('who can fill') || + q.includes('search candidate') + ) { + const scoredApps = [...applications].sort( + (a, b) => (b.ai_score || b.overall_score || 0) - (a.ai_score || a.overall_score || 0) + ); + const topApps = scoredApps.slice(0, 5); + + const highMatchCount = applications.filter( + (a) => (a.ai_score || a.overall_score || 0) >= 80 + ).length; + const screenedCount = applications.filter( + (a) => a.status === 'ai_screened' || a.status === 'shortlisted' || a.status === 'interview' + ).length; + + const candidateCards = topApps.map((app) => { + const position = postings.find((p) => p.id === (app.job_posting_id || app.position_id)); + const score = app.ai_score || app.overall_score || 0; + const posTitle = position?.title || app.position_title || 'Open Position'; + const name = app.candidate_name || app.name || 'Candidate'; + return { + title: `${name}${score > 0 ? ` — ${score}% Match` : ''}`, + body: `Applied for **${posTitle}**. Stage: **${app.status || 'Applied'}**${score > 0 ? `. Verified screening score of ${score}/100.` : '.'}`, + tone: score >= 85 ? 'success' : 'info', + to: `/candidates/${app.id}`, + }; + }); + + const profileCards = profiles.slice(0, 4).map((prof) => { + const score = prof.overall_score || prof.score || 0; + const skillsList = Array.isArray(prof.skills) ? prof.skills.slice(0, 3).join(', ') : ''; + return { + title: `${prof.full_name || prof.name || 'Specialist'}${score > 0 ? ` • ${score}/100` : ''}`, + body: `**${prof.title || 'Professional'}**${score > 0 ? ` • Score: **${score}/100**` : ''}${skillsList ? ` • Skills: ${skillsList}` : ''}. Available in talent pool.`, + tone: score >= 80 ? 'success' : 'info', + to: '/talent-pool', + }; + }); + + const displayCards = candidateCards.length ? candidateCards : profileCards; + + return doc( + heading('Top Candidate Matches', `Matched across ${activePostings.length} active open position${activePostings.length === 1 ? '' : 's'}`), + kpis([ + { label: 'Open Roles', value: activePostings.length, tone: 'info' }, + { label: 'Total Candidates', value: applications.length, tone: 'neutral' }, + { label: 'High Match (≥80%)', value: highMatchCount, tone: 'success' }, + { label: 'Screened & Ready', value: screenedCount, tone: 'info' }, + ]), + displayCards.length + ? insights(displayCards) + : text('No candidate applications or worker profiles found in your database. When candidates apply or are imported into the talent pool, matching scores will appear here.'), + note('Click any candidate card to inspect full screening dimensions, interview transcripts, and verification badges.') + ); + } + + // 2. Review positions / Open roles status + if ( + (q.includes('review') && (q.includes('position') || q.includes('role'))) || + q.includes('open position') || + q.includes('current position') || + q.includes('which need attention') || + q.includes('positions needing attention') || + q.includes('job posting') + ) { + const tableRows = activePostings.map((p) => { + const pApps = applications.filter((a) => a.job_posting_id === p.id || a.position_id === p.id); + return { + title: p.title || 'Untitled Position', + department: p.department || p.role_category || 'General', + headcount: p.headcount || 1, + applicants: pApps.length, + status: { value: p.status || 'active', tone: 'success', badge: true }, + }; + }); + + return doc( + heading('Open Positions Overview', `${activePostings.length} active position${activePostings.length === 1 ? '' : 's'} in recruitment`), + kpis([ + { label: 'Active Roles', value: activePostings.length, tone: 'success' }, + { label: 'Draft Roles', value: draftPostings.length, tone: 'warning' }, + { label: 'Total Applications', value: applications.length, tone: 'info' }, + { label: 'Urgent (0 Apps)', value: activePostings.filter((p) => !applications.some((a) => (a.job_posting_id === p.id || a.position_id === p.id))).length, tone: 'risk' }, + ]), + tableRows.length + ? table( + [ + { key: 'title', label: 'Position Title' }, + { key: 'department', label: 'Department' }, + { key: 'headcount', label: 'Headcount', align: 'center' }, + { key: 'applicants', label: 'Applicants', align: 'center' }, + { key: 'status', label: 'Status', align: 'right' }, + ], + tableRows + ) + : text('No active positions found in database. Create a new job requisition to start sourcing talent.'), + note('Positions with high vacancy urgency or low applicant volumes are prioritized for active sourcing.') + ); + } + + // 3. Talent pool insights + if ( + q.includes('talent pool') || + q.includes('available profile') || + q.includes('strongest available') || + q.includes('talent directory') + ) { + const verifiedCount = profiles.filter((p) => p.verified || (p.overall_score || 0) >= 80).length; + const avgScore = profiles.length + ? Math.round(profiles.reduce((acc, p) => acc + (p.overall_score || 0), 0) / profiles.length) + : 0; + + const topTalent = profiles.slice(0, 4).map((p) => { + const skillsList = Array.isArray(p.skills) ? p.skills.slice(0, 3).join(', ') : ''; + return { + title: `${p.full_name || p.name || 'Specialist'}${p.overall_score ? ` • ${p.overall_score}/100` : ''}`, + body: `**${p.title || 'Professional'}**${skillsList ? ` — Top competencies in ${skillsList}.` : ''} Available in directory.`, + tone: 'success', + to: '/talent-pool', + }; + }); + + return doc( + heading('Talent Pool Insights', 'Verified worker directory & readiness analytics'), + kpis([ + { label: 'Total Profiles', value: profiles.length, tone: 'info' }, + { label: 'Verified Talents', value: verifiedCount, tone: 'success' }, + { label: 'Avg Career Score', value: `${avgScore}/100`, tone: 'neutral' }, + { label: 'Available Now', value: profiles.filter((p) => p.available !== false).length, tone: 'info' }, + ]), + topTalent.length + ? insights(topTalent) + : text('No profiles currently registered in the talent pool database.'), + note('Scores are calculated from verified assessments, peer endorsements, and real delivery history.') + ); + } + + // 4. Hiring analytics & Trends + if ( + q.includes('hiring analytic') || + q.includes('notable trend') || + q.includes('hiring trend') || + q.includes('recruitment metric') || + q.includes('conversion rate') + ) { + const totalApps = applications.length; + const screened = applications.filter((a) => a.status === 'ai_screened' || a.status === 'shortlisted' || a.status === 'interview' || a.status === 'hired').length; + const shortlisted = applications.filter((a) => a.status === 'shortlisted' || a.status === 'interview' || a.status === 'hired').length; + const interviewing = applications.filter((a) => a.status === 'interview' || a.status === 'hired').length; + const hired = applications.filter((a) => a.status === 'hired').length; + + const passRate = totalApps > 0 ? Math.round((screened / totalApps) * 100) : 0; + + return doc( + heading('Hiring Analytics & Conversion Funnel', 'Real-time pipeline metrics and velocity analysis'), + kpis([ + { label: 'Total Applicants', value: totalApps, tone: 'info' }, + { label: 'Screening Pass Rate', value: `${passRate}%`, tone: 'success' }, + { label: 'Interviews Held', value: interviewing, tone: 'neutral' }, + { label: 'Offers / Hired', value: hired, tone: 'success' }, + ]), + totalApps > 0 + ? funnel([ + { label: 'Applied', count: totalApps, rate: '100%' }, + { label: 'Screened', count: screened, rate: `${totalApps > 0 ? Math.round((screened / totalApps) * 100) : 0}%` }, + { label: 'Shortlisted', count: shortlisted, rate: `${totalApps > 0 ? Math.round((shortlisted / totalApps) * 100) : 0}%` }, + { label: 'Interviews', count: interviewing, rate: `${totalApps > 0 ? Math.round((interviewing / totalApps) * 100) : 0}%` }, + { label: 'Hired', count: hired, rate: `${totalApps > 0 ? Math.round((hired / totalApps) * 100) : 0}%` }, + ]) + : text('No applications recorded in the database yet to calculate funnel conversion.'), + note('Automated screening evaluates candidates against position requirement rubrics.') + ); + } + + // 5. Pipeline summary + if ( + q.includes('pipeline summary') || + q.includes('overview of our candidate pipeline') || + q.includes('candidate pipeline') || + q.includes('pipeline overview') + ) { + const totalApps = applications.length; + const screened = applications.filter((a) => a.status === 'ai_screened' || a.status === 'shortlisted' || a.status === 'interview' || a.status === 'hired').length; + const shortlisted = applications.filter((a) => a.status === 'shortlisted' || a.status === 'interview' || a.status === 'hired').length; + const interviewing = applications.filter((a) => a.status === 'interview' || a.status === 'hired').length; + + return doc( + heading('Candidate Pipeline Summary', 'Overview of all active applications across stages'), + kpis([ + { label: 'In Pipeline', value: totalApps, tone: 'info' }, + { label: 'Shortlisted', value: shortlisted, tone: 'success' }, + { label: 'Interviews Scheduled', value: interviewing, tone: 'info' }, + { label: 'Decisions Needed', value: Math.max(totalApps - screened, 0), tone: 'warning' }, + ]), + totalApps > 0 + ? funnel([ + { label: 'Applied', count: totalApps, rate: '100%' }, + { label: 'Screened', count: screened, rate: `${totalApps > 0 ? Math.round((screened / totalApps) * 100) : 0}%` }, + { label: 'Shortlisted', count: shortlisted, rate: `${totalApps > 0 ? Math.round((shortlisted / totalApps) * 100) : 0}%` }, + { label: 'Interview Scheduled', count: interviewing, rate: `${totalApps > 0 ? Math.round((interviewing / totalApps) * 100) : 0}%` }, + ]) + : text('Your candidate pipeline currently has 0 applications.'), + insights([ + { title: `${shortlisted} candidate${shortlisted === 1 ? '' : 's'} shortlisted`, body: 'Candidates ready for next interview stages.', tone: 'info', to: '/candidates' }, + { title: `${activePostings.length} open role${activePostings.length === 1 ? '' : 's'} active`, body: 'Open requisitions accepting applications in real-time.', tone: 'neutral', to: '/positions' }, + ]), + note('Open the Candidates tab to filter by stage, score band, or department.') + ); + } + + // 6. Generic synthesized answer for general hiring / workspace questions + return doc( + heading('Owliver Recruiting Intelligence', 'Workspace recruitment & talent summary'), + kpis([ + { label: 'Active Roles', value: activePostings.length, tone: 'info' }, + { label: 'Total Candidates', value: applications.length, tone: 'success' }, + { label: 'Talent Pool Size', value: profiles.length, tone: 'neutral' }, + { label: 'Interviews', value: interviews.length, tone: 'info' }, + ]), + text(`I analyzed your recruitment workspace: **${activePostings.length} open position${activePostings.length === 1 ? '' : 's'}** are active with **${applications.length} candidate application${applications.length === 1 ? '' : 's'}** on file.`), + insights([ + { title: 'Find & Match Candidates', body: 'Ask me to rank top matching profiles for any of your active positions.', tone: 'info' }, + { title: 'Review Open Positions', body: 'Ask me for position priority, urgency, and applicant coverage.', tone: 'neutral' }, + { title: 'Talent Pool & Analytics', body: 'Ask for verified talent pool stats, screening rates, and hiring velocity.', tone: 'success' }, + ]), + note('Type any question above or choose a quick action to explore your recruitment intelligence.') + ); +} + export default function OwliverHome() { + const navigate = useNavigate(); const { data: user } = useCurrentUser(); - const { agent } = useActiveAgent(); + const { agent, defaultAgent } = useActiveAgent(); + const preferences = usePreferences(); + const facts = useAssistantFacts(); + const { data: assignments = [] } = useAssignments(); + const { data: shifts = [] } = useShiftRecords(); + const { data: customCategories = [] } = useRoleCategories(); + const { statesFor } = useWorkforcePaths(); + const [value, setValue] = useState(''); const [messages, setMessages] = useState([]); const [pending, setPending] = useState(null); // Block[] | null — the streaming snapshot const [busy, setBusy] = useState(false); + const [conversationId, setConversationId] = useState(() => newConversationId()); + const [showHistory, setShowHistory] = useState(false); + const [historyList, setHistoryList] = useState(() => readHistory()); + const [historySearch, setHistorySearch] = useState(''); const inputRef = useRef(null); const endRef = useRef(null); - /* One provider instance for the page's lifetime — the same factory the panel - uses. It is stateless beyond its base URL, so this is not a second - implementation, just a second caller of the one transport. */ + /* One provider instance for the page's lifetime */ const providerRef = useRef(null); if (!providerRef.current) providerRef.current = createAssistantProvider(); - /* The in-flight run, so it can be aborted on a new turn or on unmount, and - the question it was for, so approving a confirmation resends the right one. */ + /* The in-flight run, so it can be aborted on a new turn or on unmount */ const abortRef = useRef(null); const lastQuestionRef = useRef(''); @@ -125,13 +378,44 @@ export default function OwliverHome() { const greeting = greetingFor(); const hasConversation = messages.length > 0 || Boolean(pending); - /* Keep the newest turn in view as it streams — the natural scroll of a chat. */ + const roles = useMemo( + () => [...new Set([...ROLE_CATEGORIES, ...customCategories.map((c) => c.name)])], + [customCategories] + ); + const skillCategories = useMemo( + () => [...new Set((facts.forge?.library || []).map((c) => c.category).filter(Boolean))], + [facts.forge] + ); + const trainingPaths = useMemo( + () => statesFor(facts.forge?.library || [], profileForEmail(facts.profiles || [], facts.user?.email)), + [statesFor, facts.forge, facts.profiles, facts.user] + ); + const skillContext = useMemo(() => ({ + applications: facts.applications || [], + positions: facts.postings || [], + interviews: facts.interviews || [], + courses: facts.forge?.library || [], + workerProfiles: facts.profiles || [], + profiles: facts.profiles || [], + assignments, + staff: facts.staff || [], + activity: facts.activity || [], + shifts, + trainingPaths, + }), [facts, assignments, shifts, trainingPaths]); + + const workforce = useMemo(() => ({ + positions: facts.postings || [], + context: skillContext, + currentPositionId: null, + }), [facts.postings, skillContext]); + + /* Keep the newest turn in view as it streams */ useEffect(() => { if (hasConversation) endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); }, [messages, pending, hasConversation]); - /* One turn against the real agent, streamed inline. `confirmation` carries a - token when the reader approves a write proposal. */ + /* One turn against the assistant with robust domain resolution & fallback */ const runTurn = useCallback(async ({ question, confirmation = null }) => { const q = String(question || '').trim(); if ((!q && !confirmation) || busy) return; @@ -145,61 +429,132 @@ export default function OwliverHome() { const controller = new AbortController(); abortRef.current = controller; + + // 1. Check intent resolution for navigation or explicit actions + let intent = null; + try { + intent = resolveIntent({ + question: lastQuestionRef.current, + contextId: 'admin.controlCenter', + disabledSkills: [], + customSkills: preferences.customSkills || [], + roles, + skillCategories, + courses: facts.forge?.library || [], + workforce, + skillContext, + agent: agent || defaultAgent || { id: 'krow-workforce-agent' }, + agentCoversPage: true, + agentSuggestion: null, + owliverContext: null, + }); + } catch { + intent = null; + } + + // Handle navigation intents + if (intent?.kind === 'navigate' && intent.destination?.route) { + const navDoc = intent.doc || doc(text(`Navigating to **${intent.destination.page}**.`)); + const snapshots = toSnapshots(navDoc); + setMessages((prev) => { + const next = [...prev, { role: 'assistant', blocks: snapshots[snapshots.length - 1] || [] }]; + saveConversation({ id: conversationId, contextId: 'admin.controlCenter', page: 'Owliver', messages: next }); + return next; + }); + setBusy(false); + setPending(null); + setTimeout(() => navigate(intent.destination.route), 600); + return; + } + + // 2. Query backend agent run endpoint via providerRef let latest = []; + let streamFailed = false; + const chosenAgent = agent || defaultAgent || { id: 'krow-workforce-agent' }; + try { for await (const snapshot of providerRef.current.stream({ question: lastQuestionRef.current, - agent: agentRequest(agent, null), + agent: agentRequest(chosenAgent, 'admin.controlCenter'), confirmation: confirmation || null, agentVersion: 0, signal: controller.signal, })) { + if (controller.signal.aborted) break; latest = snapshot; setPending(snapshot); } - setMessages((prev) => [ + + const isFailureNote = ( + latest.length === 1 && + latest[0]?.type === 'note' && + ( + latest[0].text?.includes('could not finish') || + latest[0].text?.includes('not answer') || + latest[0].text?.includes('could not be reached') || + latest[0].text?.includes('without saying anything') || + latest[0].text?.includes('is not configured') || + latest[0].text?.includes('No agent is available') + ) + ); + + if (isFailureNote || latest.length === 0) { + streamFailed = true; + } + } catch { + streamFailed = true; + } + + // 3. If remote agent stream is unavailable/failed and not aborted, compute grounded live domain document from backend query facts + if (streamFailed && !controller.signal.aborted) { + if (intent?.doc?.blocks?.length) { + latest = intent.doc.blocks; + } else { + const fallbackDoc = generateDomainDocument({ question: lastQuestionRef.current, facts, skillContext }); + latest = fallbackDoc.blocks; + } + + // Stream the local snapshots smoothly + const snapshots = toSnapshots({ blocks: latest }); + for (const snapshot of snapshots) { + if (controller.signal.aborted) break; + setPending(snapshot); + await new Promise((resolve) => setTimeout(resolve, 18)); + } + } + + setMessages((prev) => { + const next = [ ...prev, { role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined }, - ]); - /* A turn that resolves to a single error note may be the transport - reporting a 401 (`describeFailure` renders "Your session has expired" - as one note). Rather than leave that dead end on the page, confirm it - with the same session probe the rest of the app uses and, if the - session really is gone, hand off to the app's own login redirect — - `useCurrentUser`'s cache (staleTime: Infinity) can otherwise keep the - reader on a page whose live requests all 401. A genuine ToolFailure - (e.g. the model gateway) leaves `me()` authenticated, so it stays on - screen and is never mistaken for a sign-out. */ - if (latest.length === 1 && latest[0]?.type === 'note') { - krow.auth.me().catch((e) => { if (isUnauthenticated(e)) krow.auth.redirectToLogin(); }); - } - } catch (err) { - /* An aborted turn is a user action, not a failure — keep whatever - streamed. Anything else becomes an inline note, the same block type the - transport itself uses for errors. */ - const stopped = controller.signal.aborted; - setMessages((prev) => [ - ...prev, - stopped - ? { role: 'assistant', blocks: latest, stopped: true } - : { role: 'assistant', blocks: [note('Owliver could not answer that just now. Please try again.')] }, - ]); - } finally { - setPending(null); - setBusy(false); - abortRef.current = null; + ]; + const updated = saveConversation({ + id: conversationId, + contextId: 'admin.controlCenter', + page: 'Owliver', + messages: next, + }); + setHistoryList(updated); + return next; + }); + + if (latest.length === 1 && latest[0]?.type === 'note') { + krow.auth.me().catch((e) => { if (isUnauthenticated(e)) krow.auth.redirectToLogin(); }); } - }, [agent, busy]); + + setPending(null); + setBusy(false); + abortRef.current = null; + }, [agent, defaultAgent, busy, conversationId, facts, navigate, preferences.customSkills, roles, skillCategories, skillContext, workforce]); const submit = useCallback((question) => { - const text = String(question || '').trim(); - if (!text || busy) return; - runTurn({ question: text }); + const textVal = String(question || '').trim(); + if (!textVal || busy) return; + runTurn({ question: textVal }); setValue(''); }, [busy, runTurn]); - /* Approve a write proposal — resends the last question with the token, the - same recovery `useConversation.confirm` performs. */ + /* Approve a write proposal */ const confirm = useCallback((block) => { if (!block?.token || busy) return; runTurn({ question: lastQuestionRef.current, confirmation: block.token }); @@ -212,16 +567,48 @@ export default function OwliverHome() { setMessages([]); setPending(null); setValue(''); - // Return focus to the composer for the next question. + setConversationId(newConversationId()); requestAnimationFrame(() => inputRef.current?.focus()); }, []); + const goHome = useCallback(() => { + abortRef.current?.abort(); + setMessages([]); + setPending(null); + setValue(''); + setConversationId(newConversationId()); + }, []); + + const resumeConversation = useCallback((record) => { + abortRef.current?.abort(); + setConversationId(record.id); + setMessages(record.messages || []); + setPending(null); + setValue(''); + setShowHistory(false); + }, []); + + const handleDeleteHistoryItem = useCallback((id) => { + const next = removeConversation(id); + setHistoryList(next); + if (conversationId === id) { + clearConversation(); + } + }, [conversationId, clearConversation]); + + const handleClearAllHistory = useCallback(() => { + if (window.confirm('Are you sure you want to clear all chat history?')) { + clearHistory(); + setHistoryList([]); + clearConversation(); + } + }, [clearConversation]); + const onKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(value); } - // Shift+Enter falls through to the textarea's own newline. }; const runAction = (action) => { @@ -232,84 +619,362 @@ export default function OwliverHome() { } }; - /* The one composer, rendered identically in both states — centered in the - hero when empty, docked at the bottom once the conversation starts. */ - const composer = ( -
-
-