Update Owliver language and suggestions
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) ?? [];
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user