Update Owliver language and suggestions

This commit is contained in:
2026-10-07 19:15:44 +05:30
parent 8ac4e1ee88
commit 81e5979f8f
156 changed files with 7330 additions and 1755 deletions

View File

@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useActiveAgent } from './AgentContext';
import { useTranslation } from 'react-i18next';
/**
* Who is answering, in Owliver's own header.
@@ -50,14 +51,16 @@ function AgentGlyph({ agent, className = 'h-7 w-7' }) {
* constrained reply arrive with no warning.
*/
function ConstrainedTag() {
const { t } = useTranslation();
return (
<span className="inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300">
Constrained
{t('agentBadge.constrained')}
</span>
);
}
export function AgentBadge({ page }: any) {
const { t } = useTranslation();
const { agent, covers } = useActiveAgent();
return (
@@ -68,7 +71,7 @@ export function AgentBadge({ page }: any) {
<div className="min-w-0 flex-1">
<h2 className="truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
Owliver
{t('agentBadge.owliver')}
</h2>
<div className="flex min-w-0 items-center gap-1.5">
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>

View File

@@ -3,6 +3,7 @@ import { ThumbsDown, ThumbsUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ResponseDocument } from './ResponseBlocks';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useTranslation } from 'react-i18next';
/**
* The thinking state, shown between sending and the first block arriving.
@@ -11,6 +12,7 @@ import OwliverAvatar from '@/components/krow/OwliverAvatar';
* reader, and the visually-hidden text is what tells one that a reply is coming.
*/
export function ThinkingIndicator() {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2">
<OwliverAvatar className="h-6 w-6" rounded="rounded-full" />
@@ -23,7 +25,7 @@ export function ThinkingIndicator() {
/>
))}
</span>
<span className="sr-only">Owliver is preparing a response</span>
<span className="sr-only">{t('assistantMessage.owliverIsPreparingAResponse')}</span>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { useAssistantPanel } from './AssistantPanelContext';
import { ResizeDivider } from './ResizeDivider';
import { useIsPhone, useViewportWidth, useVisualViewport } from './viewport';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useTranslation } from 'react-i18next';
const EXPANDED_WIDTH = 620;
/**
@@ -33,6 +34,7 @@ const MAX_VIEWPORT_SHARE = 0.42;
* in the collapsed state, where there is nowhere else for it to live.
*/
function CollapsedTrigger({ page, onRestore }) {
const { t } = useTranslation();
return (
<button
type="button"
@@ -50,7 +52,7 @@ function CollapsedTrigger({ page, onRestore }) {
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<OwliverAvatar className="h-7 w-7" rounded="rounded-full" />
<span className="text-body-sm font-semibold text-ink-1">Owliver</span>
<span className="text-body-sm font-semibold text-ink-1">{t('assistantPanel.owliver')}</span>
<PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
</button>
);
@@ -81,6 +83,7 @@ function CollapsedTrigger({ page, onRestore }) {
* instead of pushing the composer off the bottom of it.
*/
function MobileWorkspace({ context, onClose }) {
const { t } = useTranslation();
const viewport = useVisualViewport();
/* The page behind an overlay must not scroll: on a touch screen a drag that
@@ -105,7 +108,7 @@ function MobileWorkspace({ context, onClose }) {
pointer. */}
<button
type="button"
aria-label="Close the Owliver workspace"
aria-label={t('assistant.closeWorkspace')}
onClick={onClose}
className="absolute inset-0 bg-ink-1/30 backdrop-blur-[2px] motion-safe:animate-fade-in"
/>
@@ -119,7 +122,7 @@ function MobileWorkspace({ context, onClose }) {
pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
role="dialog"
aria-modal="true"
aria-label="Owliver workspace"
aria-label={t('assistant.workspace')}
>
<KrowAssistant
key={context.id}
@@ -180,6 +183,7 @@ function MobileWorkspace({ context, onClose }) {
* application layout, never a fullscreen takeover.
*/
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
const { t } = useTranslation();
const {
context, isOpen, isExpanded, width, minWidth, maxWidth,
setWidth, resetWidth, open, close, expand, restore,
@@ -221,7 +225,7 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
return (
<aside
aria-label="Owliver workspace"
aria-label={t('assistant.workspace')}
style={{ width: trackWidth }}
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2).
`min-w-0` — see (3).

View File

@@ -31,7 +31,8 @@ import { buildIntro } from './dynamic';
import { agentScopedDisabled } from '@/lib/agents/runtime';
import { buildOwliverContext } from '@/lib/agents/context';
import { AgentBadge } from './AgentBadge';
import { useLanguage } from './language';
import { useTranslation } from 'react-i18next';
import { useLanguage } from '@/lib/i18n/language';
import { useActiveAgent } from './AgentContext';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
@@ -66,12 +67,13 @@ const EMPTY_SUGGESTIONS = [];
* half-remember.
*/
function HistoryView({ groups, currentId, onOpen, onForget }) {
const { t } = useTranslation();
if (!groups.length) {
return (
<div className="animate-fade-in">
<p className="font-heading text-body font-semibold text-ink-1">No conversations yet</p>
<p className="font-heading text-body font-semibold text-ink-1">{t('krowAssistant.noConversationsYet')}</p>
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">
Ask Owliver something and it will be here afterwards. History is kept on this device.
{t('krowAssistant.askOwliverSomethingAndItWill')}
</p>
</div>
);
@@ -179,6 +181,7 @@ export const shouldShowSuggestions = ({ focused, busy, view, count }) => Boolean
* so a narrow panel on a small screen cannot push the composer off the bottom.
*/
function SuggestedQuestions({ prompts, open, onSelect }) {
const { t } = useTranslation();
return (
<div
className={cn(
@@ -190,7 +193,7 @@ function SuggestedQuestions({ prompts, open, onSelect }) {
aria-hidden={open ? undefined : 'true'}
>
<p className="px-1 pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
Suggested questions
{t('krowAssistant.suggestedQuestions')}
</p>
{/* The existing chip component, and the existing submit path behind it —
`runPrompt` is the same handler a typed question goes through. */}
@@ -410,6 +413,7 @@ export default function KrowAssistant({
the conversation. It applies from the next answer onward — answers already
on screen stay as they were written. */
const language = useLanguage();
const { t } = useTranslation();
/**
* A skill asked for something to happen. The names come from the skill file;
@@ -688,9 +692,12 @@ export default function KrowAssistant({
/* Greeting and suggestions come from live data, so they recompute only when
the data or the page actually changes. */
/* `t` is a dependency, not just an argument: react-i18next returns a new one
when the language changes, so the greeting recomputes on a switch rather
than staying in whichever language the panel first opened in. */
const intro = React.useMemo(
() => buildIntro(context.id, facts, userName),
[context.id, facts, userName]
() => buildIntro(context.id, facts, userName, t),
[context.id, facts, userName, t]
);
/**
@@ -917,7 +924,7 @@ export default function KrowAssistant({
data-wide={expanded || undefined}
className={cn('relative flex flex-col overflow-hidden', className)}
role="region"
aria-label="Owliver assistant"
aria-label={t('assistant.assistant')}
>
{/* Header */}
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
@@ -940,7 +947,7 @@ export default function KrowAssistant({
<div className="flex items-center gap-0.5">
{/* The way back to a clean panel, positioned in front of History */}
{canGoHome && (
<IconButton icon={Home} label="Back to home" variant="ghost" size="sm" onClick={goHome} />
<IconButton icon={Home} label={t('assistant.backToHome')} variant="ghost" size="sm" onClick={goHome} />
)}
{/* History lives with the other window controls rather than in the
body, so the layout of the panel is unchanged whether or not
@@ -956,13 +963,13 @@ export default function KrowAssistant({
/>
{expanded
? onRestore && (
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
<IconButton icon={Minimize2} label={t('assistant.restoreWidth')} variant="ghost" size="sm" onClick={onRestore} />
)
: onExpand && (
<IconButton icon={Maximize2} label="Expand the Owliver workspace" variant="ghost" size="sm" onClick={onExpand} />
<IconButton icon={Maximize2} label={t('assistant.expandWorkspace')} variant="ghost" size="sm" onClick={onExpand} />
)}
{onClose && (
<IconButton icon={PanelRightClose} label="Collapse the Owliver workspace" variant="ghost" size="sm" onClick={onClose} />
<IconButton icon={PanelRightClose} label={t('assistant.collapseWorkspace')} variant="ghost" size="sm" onClick={onClose} />
)}
</div>
</div>
@@ -998,7 +1005,7 @@ export default function KrowAssistant({
<button
type="button"
onClick={clearTest}
aria-label="Dismiss test indicator"
aria-label={t('assistant.dismissTestIndicator')}
className={`shrink-0 rounded p-0.5 text-ink-4 transition-colors hover:text-ink-2
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
>

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/**
* PromptChips — suggested prompts.
@@ -30,6 +31,7 @@ export function PromptChips({
something invisible is a keyboard user losing their place. */
focusable = true,
}: any) {
const { t } = useTranslation();
const chipRefs = React.useRef([]);
const visible = max ? prompts.slice(0, max) : prompts;
@@ -47,7 +49,7 @@ export function PromptChips({
return (
<div
role="group"
aria-label="Suggested prompts"
aria-label={t('assistant.suggestedPrompts')}
className={cn(
'flex flex-wrap gap-1.5',
align === 'center' ? 'justify-center' : 'justify-start',

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { ArrowUp, Square } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/**
* PromptInput — the assistant's primary action.
@@ -28,6 +29,7 @@ export function PromptInput({
align = 'left',
className = '',
}: any) {
const { t } = useTranslation();
const textareaRef = React.useRef(null);
const [focused, setFocused] = React.useState(false);
@@ -79,7 +81,7 @@ export function PromptInput({
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
disabled={busy}
aria-label="Ask Owliver"
aria-label={t('assistant.ask')}
aria-busy={busy || undefined}
className={cn(
'block w-full resize-none bg-transparent leading-relaxed text-ink-1',
@@ -101,7 +103,7 @@ export function PromptInput({
isCenter ? 'text-center' : 'text-left'
)}
>
{busy ? 'Thinking…' : placeholders[0]}
{busy ? t('assistant.thinking') : placeholders[0]}
</span>
)}
</div>
@@ -110,7 +112,7 @@ export function PromptInput({
<button
type="button"
onClick={onStop}
aria-label="Stop generating"
aria-label={t('assistant.stopGenerating')}
className={cn(
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2',
'transition-colors duration-base hover:bg-surface-sunken focus-ring',
@@ -123,7 +125,7 @@ export function PromptInput({
<button
type="submit"
disabled={!value.trim()}
aria-label="Send message"
aria-label={t('assistant.sendMessage')}
className={cn(
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
'transition-all duration-base ease-out hover:bg-krow-blue-dark',

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/**
* ResizeDivider — the drag handle between the dashboard and the Owliver panel.
@@ -15,6 +16,7 @@ import { cn } from '@/lib/utils';
* limits, and the whole thing is exposed as a `separator` with live values.
*/
export function ResizeDivider({ width, min, max, onResize, onDoubleClick, className }) {
const { t } = useTranslation();
const [dragging, setDragging] = React.useState(false);
/* Pointer events on the window rather than the element: once a drag starts the
@@ -66,7 +68,7 @@ export function ResizeDivider({ width, min, max, onResize, onDoubleClick, classN
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize Owliver panel"
aria-label={t('assistant.resizePanel')}
aria-valuenow={width}
aria-valuemin={min}
aria-valuemax={max}

View File

@@ -814,8 +814,19 @@ const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] ||
* Everything the landing screen needs, computed from current data.
* `userName` personalizes the greeting where a name is known.
*/
export function buildIntro(contextId, facts, userName) {
export function buildIntro(contextId, facts, userName, t = null) {
const name = firstName(userName);
/* `t` is optional so a caller that has not been converted yet still gets the
English it got before. Every map below keeps its English function; the
translated path is taken only where a key exists, which is what lets this
file be converted a context at a time instead of in one unreviewable
change. */
if (t) {
const translated = translatedIntro(contextId, facts, name, t);
if (translated) return translated;
}
return {
title: TITLES[contextId]?.(facts, name) ?? 'Owliver',
greeting: GREETINGS[contextId]?.(facts) ?? '',
@@ -824,6 +835,54 @@ export function buildIntro(contextId, facts, userName) {
};
}
/**
* The translated intro for a context, or null where there is not one yet.
*
* The greeting is NOT the same sentence with the words swapped. "Good morning"
* is one key per time of day rather than "Good " plus a translated noun,
* because Spanish says "Buenos días" and "Buenas tardes" — the adjective agrees
* with the noun, so a sentence assembled from parts is wrong in one of them.
* The same reasoning is why counts go through i18next's own plural forms rather
* than through `plural()`, which appends an English "s".
*/
function translatedIntro(contextId, facts, name, t) {
const part = timeOfDay(facts.today);
const title = name
? t(`assistant.greeting.${part}Named`, { name })
: t(`assistant.greeting.${part}`);
if (contextId === 'admin.controlCenter') {
/* The greeting picks ONE line by urgency, the same order the English map
uses — something unscored beats something merely recent, which beats
"steady". Rebuilt here rather than translated after the fact, because the
English version has already chosen its words and its plural by the time
it returns a string. */
const greeting = facts.unscoredProfiles?.length
? t('ccAssistant.unscored', { count: facts.unscoredProfiles.length })
: facts.activity24h?.length
? t('ccAssistant.events', { count: facts.activity24h.length })
: t('ccAssistant.steady', {
positions: t('assistant.controlCenter.positions', { count: facts.postings.length }),
applications: t('assistant.controlCenter.applications', { count: facts.total }),
});
return {
title,
greeting,
description: t('assistant.controlCenter.description', {
applications: t('assistant.controlCenter.applications', { count: facts.total }),
positions: t('assistant.controlCenter.positions', { count: facts.postings.length }),
}),
placeholders: [t('ccAssistant.placeholder')],
};
}
/* Every other context still answers in English, deliberately: a half
translated intro that silently falls back is better than one assembled
from a key that does not exist. */
return null;
}
/** Suggested prompts for the current page and data. */
export function buildPrompts(contextId, facts, workforce = null) {
return PROMPTS[contextId]?.(facts, workforce) ?? [];

View File

@@ -95,6 +95,14 @@ export function buildFacts({
const transitions = funnel.slice(1).map((stage, i) => ({
from: funnel[i].label,
to: stage.label,
/* The same two stages by KEY. `from`/`to` are English labels and several
callers render them directly; the keys are what a translated surface
needs, because a label cannot be looked up in a catalogue — asking for
`stage.Interviewed` returns that string verbatim, which is how a raw key
reached the screen. Both are carried rather than one replaced, so no
existing reader changes behaviour. */
fromKey: funnel[i].key,
toKey: stage.key,
rate: pct(stage.count, funnel[i].count),
lost: Math.max(0, funnel[i].count - stage.count),
}));

View File

@@ -1,142 +0,0 @@
import * as React from 'react';
/**
* The language Owliver answers in.
*
* One list, read by the header's selector and by the provider that sends the
* run. A second copy would be two lists that could disagree, and the way that
* shows up is a selector offering a language the backend does not answer in.
*
* The tags are the backend's: `internal/runtime/language.go` holds the same
* set, and the string sent here SELECTS a directive there rather than becoming
* one. That is deliberate and it is a security boundary, not a style — a tag
* this file invented would silently fall back to English rather than being
* interpolated into a prompt.
*
* This is the panel's language, not the product's. Nothing else in the
* dashboard is translated, and the selector does not claim to be: it governs
* what Owliver writes back, which is the only text here that is generated
* rather than designed.
*/
/** The offered languages, in the order the selector shows them. */
export const LANGUAGES = [
/* English first and English default. Every agent's instructions are written
in English, so it is the language that needs no directive at all. */
{ tag: 'en', label: 'English' },
{ tag: 'es', label: 'Español' },
];
export const DEFAULT_LANGUAGE = 'en';
/**
* Where the choice is kept.
*
* `localStorage`, deliberately — not thread state. Somebody who reads in
* Spanish reads in Spanish on every page and after every reload; making it
* per-conversation would ask them to set it again each time the panel opened,
* which is the behaviour of a filter rather than of a preference.
*
* Not persisted into the thread record either: replaying an old conversation
* shows the answers as they were written, and re-labelling them with today's
* preference would claim a Spanish answer had been given in English.
*/
const STORAGE_KEY = 'krow_owliver_language';
/** Whether `tag` is a language this build offers. */
export function isLanguage(tag) {
return LANGUAGES.some((l) => l.tag === tag);
}
/**
* The stored preference, or English.
*
* Tolerant of everything storage can do: a tag written by an older build, a
* browser that denies access, private mode. The question "which language" must
* always have an answer, and an unreadable store means the default rather than
* a crash on the panel's first render.
*/
export function readLanguage() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return isLanguage(stored) ? stored : DEFAULT_LANGUAGE;
} catch {
return DEFAULT_LANGUAGE;
}
}
/** Stores the preference. A failure to write is not worth breaking a render. */
export function writeLanguage(tag) {
try {
if (isLanguage(tag)) localStorage.setItem(STORAGE_KEY, tag);
} catch {
/* Private mode, or storage denied. The selector still works for this
session; it just will not be remembered after a reload. */
}
}
/** The label for a tag, for the selector and for an aria-label. */
export function languageLabel(tag) {
return LANGUAGES.find((l) => l.tag === tag)?.label || 'English';
}
/**
* Subscribers to the choice.
*
* The selector lives in the account menu and the thing it governs lives in the
* Owliver panel — two subtrees with no parent between them short of the app
* root. A context provider spanning both would be a provider added for one
* string; an external store is the smaller change and it also gets cross-tab
* sync for free, since `storage` fires in the OTHER tab and nothing else would
* have told it.
*/
const listeners = new Set<() => void>();
/** Cached so `getSnapshot` is cheap and, more importantly, stable. */
let current = null;
function snapshot() {
if (current === null) current = readLanguage();
return current;
}
function subscribe(listener) {
listeners.add(listener);
/* Another tab changed it. `storage` does not fire in the tab that wrote, so
this handles only the other ones; `setLanguage` notifies this one. */
const onStorage = (e) => {
if (e.key !== null && e.key !== STORAGE_KEY) return;
current = readLanguage();
listeners.forEach((l) => l());
};
window.addEventListener('storage', onStorage);
return () => {
listeners.delete(listener);
window.removeEventListener('storage', onStorage);
};
}
/**
* Sets the language everywhere.
*
* Writes through to storage and notifies every reader in this tab. Ignores a
* tag this build does not offer, so a stale menu cannot put the app into a
* language the backend will not answer in.
*/
export function setLanguage(tag) {
if (!isLanguage(tag) || tag === snapshot()) return;
current = tag;
writeLanguage(tag);
listeners.forEach((l) => l());
}
/**
* The current language, re-rendering the caller when it changes.
*
* `useSyncExternalStore` rather than state plus an effect: the panel must read
* the value that is true at render time, and a value corrected in an effect is
* one question answered in the wrong language.
*/
export function useLanguage() {
return React.useSyncExternalStore(subscribe, snapshot, () => DEFAULT_LANGUAGE);
}

View File

@@ -25,7 +25,7 @@ import { agentRequest } from '@/lib/agents/runtime';
import { createAssistantProvider } from './provider';
import { preferAgent, resolveIntent } from './routing';
import { doc, text as textBlock, toSnapshots } from './blocks';
import { DEFAULT_LANGUAGE } from './language';
import { DEFAULT_LANGUAGE } from '@/lib/i18n/language';
/** One provider instance for the app's lifetime. */
const provider = createAssistantProvider();