update ui design for all pages

This commit is contained in:
2026-08-12 18:56:56 +05:30
parent 25c4ee4a6a
commit 3beb66059b
58 changed files with 7892 additions and 1891 deletions

View File

@@ -1,9 +1,15 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import { useCreateJobPosting, usePreferences, useRoleCategories } from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
import { runAction } from '@/lib/skills/actions';
import { skillsForContext } from '@/lib/skills/registry';
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
import { buildIntro, buildPrompts } from './dynamic';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
@@ -42,9 +48,83 @@ export default function KrowAssistant({
}) {
const facts = useAssistantFacts();
const userName = useCurrentUserName();
const navigate = useNavigate();
/* The app's own router, not a location assignment: a full page load would
discard the thread and the panel state along with it. */
const goToPage = React.useCallback(
(destination) => navigate(destination.route),
[navigate]
);
/* Skills available here, and the roles they can act on — both read from what
the app already has rather than from anything written into this component.
A page whose skills change needs no edit here. */
const preferences = usePreferences();
const disabledSkills = React.useMemo(
() => preferences.disabledSkills || [],
[preferences.disabledSkills]
);
const { data: customCategories = [] } = useRoleCategories();
const roles = React.useMemo(
() => [...new Set([...ROLE_CATEGORIES, ...customCategories.map((c) => c.name)])],
[customCategories]
);
/* The categories Forge skills are actually filed under, so "create a kitchen
safety training" fills in the category this deployment uses rather than one
written into a skill file. */
const skillCategories = React.useMemo(
() => [...new Set((facts.forge?.library || []).map((c) => c.category).filter(Boolean))],
[facts.forge]
);
const customSkills = React.useMemo(
() => preferences.customSkills || [],
[preferences.customSkills]
);
const skills = React.useMemo(
() => skillsForContext(context.id, disabledSkills, customSkills),
[context.id, disabledSkills, customSkills]
);
const [addSkillOpen, setAddSkillOpen] = React.useState(false);
/**
* A skill asked for something to happen. The names come from the skill file;
* `runAction` decides what they mean and refuses anything the file did not
* declare.
*/
const performAction = React.useCallback((action, skill) => {
const result = runAction(action.name, { ...action.payload, skill });
if (result?.type === 'navigate' && result.route) {
navigate(result.route, result.state ? { state: result.state } : undefined);
}
}, [navigate]);
/**
* Write the position the conversation collected.
*
* The same mutation the Create Position form calls, so there is one create
* path and the list behind the panel refreshes the way it always did. The
* skill still has to declare `create_position` — `runAction` returns nothing
* for a skill whose file does not, and then nothing is written.
*/
const createJob = useCreateJobPosting();
const createPosition = React.useCallback(async (draft, skill) => {
const result = runAction('create_position', { draft, skill });
if (result?.type !== 'create_position') return null;
return createJob.mutateAsync(result.data);
}, [createJob]);
const { messages, pending, error, busy, send, stop, reset } = useConversation({
contextId: context.id,
facts,
onNavigate: goToPage,
onAction: performAction,
onCreatePosition: createPosition,
disabledSkills,
customSkills,
roles,
skillCategories,
courses: facts.forge?.library || [],
});
const [input, setInput] = React.useState('');
@@ -57,7 +137,17 @@ export default function KrowAssistant({
() => buildIntro(context.id, facts, userName),
[context.id, facts, userName]
);
const prompts = React.useMemo(() => buildPrompts(context.id, facts), [context.id, facts]);
/* Suggestions are the page's own, with any skill that offers one on the front.
The last answer can also propose follow-ups — the role list after "create a
position" — which replace the standing set until the next turn. */
const followUp = messages[messages.length - 1]?.followUp;
const prompts = React.useMemo(() => {
if (followUp?.length) return followUp;
const skillPrompts = skills
.filter((s) => s.prompt)
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
return [...skillPrompts, ...buildPrompts(context.id, facts)];
}, [followUp, skills, context.id, facts]);
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
at streaming frequency a smooth scroll never catches up and the thread
@@ -67,19 +157,36 @@ export default function KrowAssistant({
if (el) el.scrollTop = el.scrollHeight;
}, [messages, pending]);
const submit = React.useCallback((text) => {
const question = String(text).trim();
if (!question || busy) return;
send({ question });
/**
* The one entry point for every message, whatever raised it.
*
* A chip carries a capability — it *is* one of this page's answers, so it
* bypasses intent routing. A typed question carries none and is routed. Both
* land in the same `send`, so there is a single pipeline to reason about.
*/
const handleMessage = React.useCallback((question, capability = null) => {
const text = String(question).trim();
if (!text || busy) return;
setInput('');
send({ question: text, capability });
}, [busy, send]);
/* A chip is already a complete question, so it runs on click. */
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
/**
* A chip is already a complete question, so it runs on click.
*
* A chip may instead carry a destination — "View position", offered after
* Owliver creates one. That is the user choosing to go somewhere after the
* work is finished, which is the only navigation any of this does.
*/
const runPrompt = React.useCallback((prompt) => {
if (busy) return;
setInput('');
send({ question: prompt.prompt, capability: prompt.capability });
}, [busy, send]);
if (prompt.route) {
navigate(prompt.route);
return;
}
handleMessage(prompt.prompt, prompt.capability);
}, [handleMessage, navigate]);
const composer = (
<PromptInput
@@ -116,6 +223,15 @@ export default function KrowAssistant({
{/* Window controls */}
<div className="flex items-center gap-0.5">
{/* Opens the same dialog Settings does; there is one registry behind
both, so a skill added here is the skill added there. */}
<IconButton
icon={Sparkles}
label="Add an Owliver skill"
variant="ghost"
size="sm"
onClick={() => setAddSkillOpen(true)}
/>
{messages.length > 0 && (
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
)}
@@ -179,17 +295,23 @@ export default function KrowAssistant({
)}
</div>
<AddSkillDialog open={addSkillOpen} onOpenChange={setAddSkillOpen} />
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
{/* All suggestions on the landing screen, where they teach what can be
asked. Capped once a thread exists, because from then on the vertical
space belongs to the conversation. Expanded fits more per line, so it
can afford one more. */}
can afford one more.
Follow-ups are never capped: when Owliver has asked a question, its
chips *are* the answers, and hiding three of the six roles would make
the flow look broken. */}
{!busy && (
<PromptChips
prompts={prompts}
onSelect={runPrompt}
max={isEmpty ? undefined : expanded ? 4 : 3}
max={isEmpty || followUp?.length ? undefined : expanded ? 4 : 3}
align="start"
/>
)}