diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs
index d7c2327..fb80542 100644
--- a/scripts/skill-check.mjs
+++ b/scripts/skill-check.mjs
@@ -5755,6 +5755,48 @@ console.log('\n── Follow-ups ──');
question: 'x', history: [],
refresh: async () => [PAGE[0], PAGE[0], PAGE[1]],
})).length === 2);
+
+ /* The defect that made the row appear exactly once per conversation: every
+ chip ever shown was banned for good, so the second turn filtered out the
+ same top-ranked catalogue the first turn had consumed and the row went
+ silent for the rest of the thread. Offered is not asked — a suggestion
+ passed over, with an answer since, is offerable again. */
+ record('a chip offered earlier, but not under the last answer, comes back',
+ JSON.stringify(said(await nextSteps({
+ question: 'x',
+ history: [
+ { role: 'user', text: 'a' },
+ { role: 'assistant', followUp: [PAGE[0]] },
+ { role: 'user', text: 'b' },
+ { role: 'assistant', followUp: [PAGE[1]] },
+ ],
+ refresh: async () => [PAGE[0], PAGE[1]],
+ }))) === JSON.stringify([PAGE[0].prompt]));
+
+ /* ...while the row directly above is still never redrawn verbatim. */
+ record('...but the row under the answer directly above is not repeated',
+ (await nextSteps({
+ question: 'x',
+ history: [
+ { role: 'user', text: 'a' },
+ { role: 'assistant', followUp: [PAGE[0], PAGE[1]] },
+ ],
+ refresh: async () => [PAGE[0], PAGE[1]],
+ })) === undefined);
+
+ /* A question actually put stays excluded however long ago it was asked —
+ that exclusion is the permanent one. */
+ record('a question asked earlier in the thread is never offered back',
+ (await nextSteps({
+ question: 'x',
+ history: [
+ { role: 'user', text: 'How healthy is the platform right now?' },
+ { role: 'assistant', followUp: [] },
+ { role: 'user', text: 'b' },
+ { role: 'assistant', followUp: [] },
+ ],
+ refresh: async () => [PAGE[0]],
+ })) === undefined);
}
console.log('\n── Streaming ──');
diff --git a/src/components/ai-assistant/KrowAssistant.tsx b/src/components/ai-assistant/KrowAssistant.tsx
index e54f137..0468bf3 100644
--- a/src/components/ai-assistant/KrowAssistant.tsx
+++ b/src/components/ai-assistant/KrowAssistant.tsx
@@ -31,6 +31,7 @@ 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 { useActiveAgent } from './AgentContext';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
@@ -403,6 +404,13 @@ export default function KrowAssistant({
*/
const [view, setView] = React.useState('chat');
+ /* The account's language, chosen in the account menu rather than here. The
+ panel reads it and never sets it: it is an application setting that happens
+ to govern the one piece of text this product generates, not a property of
+ the conversation. It applies from the next answer onward — answers already
+ on screen stay as they were written. */
+ const language = useLanguage();
+
/**
* 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
@@ -637,6 +645,7 @@ export default function KrowAssistant({
agentCoversPage,
agentSuggestion: defaultAgent,
owliverContext,
+ language,
});
/* A block inside an answer asking the next question, in place. Same entry
diff --git a/src/components/ai-assistant/language.ts b/src/components/ai-assistant/language.ts
new file mode 100644
index 0000000..54fccea
--- /dev/null
+++ b/src/components/ai-assistant/language.ts
@@ -0,0 +1,142 @@
+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);
+}
diff --git a/src/components/ai-assistant/provider.ts b/src/components/ai-assistant/provider.ts
index 4eea613..b5b3b41 100644
--- a/src/components/ai-assistant/provider.ts
+++ b/src/components/ai-assistant/provider.ts
@@ -22,6 +22,7 @@
* capability: string | null, // capability id, or null for free text
* question: string, // what the user typed, or the chip's prompt
* facts: object, // dashboard fact sheet (see insights.js)
+ * language: string | null, // language tag to answer in (see language.js)
* signal: AbortSignal, // aborts an in-flight response
* }
*/
@@ -68,7 +69,7 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
},
/** The run itself. Public only so `stream` can wrap it; call `stream`. */
- async *run({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
+ async *run({ question, agent = null, confirmation = null, agentVersion = 0, language = null, signal }) {
/* No agent, no run. The panel resolves which agent covers the page before
calling; reaching here without one means the routing layer changed and
this should say so rather than guess at an agent id. */
@@ -98,6 +99,15 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
stops an edit published mid-thread from silently changing which
agent is answering. */
agentVersion: agentVersion || undefined,
+ /* The language to answer in. Omitted when it is the default, so an
+ English run sends exactly the body it sent before the selector
+ existed — and a deployment that does not know the field yet is
+ unaffected by the common case.
+
+ A tag, never a sentence. The server maps it onto a closed set and
+ an unrecognised one answers in English; the panel cannot write
+ prompt text from here, which is the point of sending a tag. */
+ language: language && language !== 'en' ? language : undefined,
}),
signal,
});
diff --git a/src/components/ai-assistant/useAssistant.ts b/src/components/ai-assistant/useAssistant.ts
index 3c80c59..9f33a67 100644
--- a/src/components/ai-assistant/useAssistant.ts
+++ b/src/components/ai-assistant/useAssistant.ts
@@ -25,6 +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';
/** One provider instance for the app's lifetime. */
const provider = createAssistantProvider();
@@ -199,14 +200,26 @@ const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/
*
* Two rules, and the second is the one that matters. The suggestions are ranked
* by the SERVER against the question just asked — the panel does not decide what
- * is worth asking, it only decides what has already been said — and then
- * anything this thread has asked or already offered is removed.
+ * is worth asking, it only decides what has already been said — and then what
+ * this thread has already covered is removed.
*
* Without that second rule the row repeats. A page carries a handful of intents
* and the top of that list barely moves between turns, so the same three chips
* come back after every answer, including the one the reader has just pressed.
- * Removing what has been used leaves genuinely new ground each time and runs out
- * honestly rather than looping.
+ *
+ * ASKED AND OFFERED ARE NOT THE SAME THING, and treating them as one is what
+ * made the row appear exactly once per conversation. Every chip ever *shown*
+ * was being banned permanently, in a set that only grew; since the server keeps
+ * returning the top of the same small catalogue, the second turn's suggestions
+ * were all already in it, the filter emptied the list, and an empty list means
+ * no row. Every turn after the first was silent, whatever was asked.
+ *
+ * So the two are separated. A question this thread actually PUT is excluded for
+ * good — it has an answer on screen, and offering to repeat it is not a follow-
+ * up. A chip merely DISPLAYED and passed over is held back only from the turn
+ * directly after it, which is enough to stop the row redrawing verbatim under
+ * consecutive answers; beyond that it is offerable again, because a reader
+ * ignoring a suggestion is not the same as having covered it.
*
* There is NO fallback to the page's own ranking, and that is the correction a
* live run forced. Asking "Summarize hiring activity" matches nothing in the
@@ -220,18 +233,34 @@ const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/
export async function nextSteps({ question, history, refresh }) {
if (!refresh) return undefined;
- const used = new Set([asQuestion(question)]);
+ /* Permanent: what this thread has actually asked, including the question
+ being answered right now. */
+ const asked = new Set([asQuestion(question)]);
for (const message of history) {
- if (message.role === 'user') used.add(asQuestion(message.text));
- for (const chip of message.followUp || []) used.add(asQuestion(chip.prompt || chip.label));
+ if (message.role === 'user') asked.add(asQuestion(message.text));
}
+ /* Transient: the row under the answer directly above this one, and only
+ that row. The immediately preceding assistant turn, whether or not it
+ carried chips — if it offered none there is nothing to hold back, and a
+ suggestion last seen further up the thread has had an answer between it
+ and now, so repeating it is not the verbatim redraw this guards against. */
+ const justOffered = new Set();
+ for (let i = history.length - 1; i >= 0; i -= 1) {
+ if (history[i]?.role !== 'assistant') continue;
+ for (const chip of history[i].followUp || []) {
+ justOffered.add(asQuestion(chip.prompt || chip.label));
+ }
+ break;
+ }
+
+ const seen = new Set();
const unused = (chips) => (chips || []).filter((chip) => {
const key = asQuestion(chip.prompt || chip.label);
- if (!key || used.has(key)) return false;
+ if (!key || asked.has(key) || justOffered.has(key) || seen.has(key)) return false;
/* A list that repeats itself within one turn is the same defect at a
smaller scale. */
- used.add(key);
+ seen.add(key);
return true;
});
@@ -336,6 +365,11 @@ export function useConversation({
/* The page's own name, recorded with an archived thread so History can say
where a conversation happened without resolving the context again. */
pageLabel = '',
+ /* The language the agent answers in — a tag from `language.js`, owned by the
+ header's selector. Read here only to put it on the request: nothing in this
+ hook branches on it, because translating is the model's job and not the
+ panel's. */
+ language = DEFAULT_LANGUAGE,
}) {
/**
* One thread per agent per page.
@@ -888,6 +922,9 @@ export function useConversation({
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
owliverContext,
agentVersion: pinnedVersionRef.current,
+ /* The reader's choice, applied from the next answer onward. Answers
+ already on screen are left as they were written. */
+ language,
})) {
if (controller.signal.aborted) break;
latest = snapshot;
@@ -968,7 +1005,7 @@ export function useConversation({
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
- agent, agentCoversPage, agentSuggestion, owliverContext,
+ agent, agentCoversPage, agentSuggestion, owliverContext, language,
/* The layout session changes as a page's composition and the account's
skills resolve, and a stale one means the tree Owliver inspects is the
empty one from the first render — so a layout request falls through to
@@ -1018,6 +1055,9 @@ export function useConversation({
owliverContext,
confirmation: block.token,
agentVersion: pinnedVersionRef.current,
+ /* The reader's choice, applied from the next answer onward. Answers
+ already on screen are left as they were written. */
+ language,
signal: controller.signal,
})) {
if (controller.signal.aborted) break;
@@ -1037,7 +1077,7 @@ export function useConversation({
setPending(null);
abortRef.current = null;
}
- }, [contextId, facts, agent, owliverContext, persist]);
+ }, [contextId, facts, agent, owliverContext, language, persist]);
/**
* States something in the thread without a question having been asked.
diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx
index 0f0f8d4..d4b62c1 100644
--- a/src/layouts/AdminLayout.tsx
+++ b/src/layouts/AdminLayout.tsx
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
- Bell, ChevronDown, LayoutGrid, LogOut, Menu, Search, Settings, Shield,
+ Bell, ChevronDown, Globe, LayoutGrid, LogOut, Menu, Search, Settings, Shield,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { base44 } from '@/api/base44Client';
@@ -11,10 +11,13 @@ import { Avatar } from '@/components/ds/Avatar';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
- DropdownMenuSeparator, DropdownMenuTrigger,
+ DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem,
+ DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent,
+ DropdownMenuSubTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant';
+import { LANGUAGES, setLanguage, useLanguage } from '@/components/ai-assistant/language';
import { UiEditingProvider } from '@/components/ui-tree/UiEditingProvider';
import { pageKeyForRoute } from '@/lib/skills/registry';
import { endAdminSession } from '@/lib/admin/session';
@@ -164,6 +167,11 @@ export default function AdminLayout() {
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
+ /* The account's language. Held outside React so the Owliver panel — a
+ different subtree entirely — sees the change without a provider spanning
+ both, and so a second tab picks it up. See ai-assistant/language. */
+ const language = useLanguage();
+
/* Shared cache rather than a local fetch, so saving the profile updates the
header avatar and account menu without a reload. */
const { data: user } = useCurrentUser();
@@ -289,6 +297,45 @@ export default function AdminLayout() {
navigate('/admin/workspace/agents')} className="cursor-pointer">
Agent Registry
+
+ {/* Language.
+
+ A submenu rather than a row that navigates, because there
+ is nothing to navigate TO: the whole setting is one choice
+ from a closed list, and a page built to hold a single
+ radio group would be a page nobody returns to.
+
+ A radio group rather than two items, so the control states
+ what it is — one of these, exactly one selected — to a
+ screen reader and to the eye, without the menu having to
+ draw its own tick.
+
+ What it governs is Owliver's replies. Nothing else in the
+ product is generated text, so nothing else changes; the
+ row is in the account menu rather than in the panel
+ because it is a property of the account and outlives any
+ one conversation. */}
+
+
+ Language
+
+
+
+
+ {LANGUAGES.map((lang) => (
+
+ {lang.label}
+
+ ))}
+
+
+
+
+