2 Commits

Author SHA1 Message Date
8ac4e1ee88 Let the reader choose Owliver's language, and stop the suggestion row going silent
Some checks failed
CI / check (pull_request) Failing after 5m8s
Two changes to the assistant panel.

THE LANGUAGE SELECTOR. An account setting in the header menu, not a property of
the conversation: somebody who reads in Spanish reads in Spanish on every page
and after every reload, and making it per-thread would ask them to set it again
each time the panel opened. Held in localStorage and outside React, so the
Owliver panel — a different subtree from the header — sees the change without a
provider spanning both, and a second tab picks it up.

A TAG is sent, never a sentence, and the field is omitted entirely when the
choice is English. The backend maps it onto a closed set
(internal/runtime/language.go holds the same list) and an unrecognised tag
answers in English. The panel therefore cannot write prompt text from here,
which is the point: the selected string SELECTS a directive rather than
becoming one.

THE SUGGESTION ROW. It appeared exactly once per conversation and was silent
after that, whatever was asked. Every chip ever SHOWN was banned permanently in
a set that only grew; the server keeps returning the top of the same small
catalogue, so by the second turn every suggestion was already in it, the filter
emptied the list, and an empty list draws no row.

Asked and offered are not the same thing. 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 ignoring
a suggestion is not the same as having covered it.

Verified: 1710/1710 skill-checks, a clean typecheck, build and lint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 15:27:02 +05:30
1ef0c6fc8a layout change ui fix
Some checks failed
CI / check (push) Failing after 5m6s
2026-09-29 16:59:13 +05:30
12 changed files with 779 additions and 100 deletions

View File

@@ -0,0 +1,134 @@
/**
* returnTo validation check.
*
* Runs the REAL module through Vite's SSR loader, the same way
* skill-check.mjs does, so the `@/` alias and the TypeScript compile are the
* app's own rather than a reimplementation of them. No test framework is added
* for a 130-line module; this follows the convention already in this directory.
*
* node scripts/authreturnto-check.mjs
*
* Exits non-zero on failure, so it can gate a build.
*
* WHAT THIS IS DEFENDING
*
* safeReturnTo decides whether a URL somebody else supplied may be navigated
* to. The cases below are therefore mostly hostile input, and each asserts the
* result is null rather than merely "not the attacker's value" — a wrong answer
* that is still a navigation is not a pass.
*/
import { createServer } from 'vite';
const results = [];
const record = (name, pass, detail = '') => {
results.push({ name, pass, detail });
console.log(`[${pass ? ' ok ' : ' FAIL '}] ${name}${detail ? ` — ${detail}` : ''}`);
};
const ORIGIN = 'https://platform.krowforce.com';
// A real authorization URL, with every parameter the flow depends on, built the
// way the Go server builds it: path + RawQuery, percent-escaped into ?returnTo=.
const AUTHORIZE =
'/oauth/authorize?client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22' +
'&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback' +
'&response_type=code' +
'&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' +
'&code_challenge_method=S256' +
'&resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp' +
'&scope=krow.read' +
'&state=vT7nQ2xK_Lp9';
const q = (v) => '?returnTo=' + encodeURIComponent(v);
const server = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' });
try {
globalThis.window = { location: { origin: ORIGIN, search: '' } };
const { safeReturnTo } = await server.ssrLoadModule('/src/lib/authReturnTo.ts');
/* ── 1–2. The OAuth authorize URL, and its query byte for byte ─────────── */
const oauth = safeReturnTo(q(AUTHORIZE));
record('1. /oauth/authorize is accepted', oauth !== null && oauth.path === AUTHORIZE,
oauth ? `via=${oauth.via}` : 'returned null');
record('12. and is marked for full browser navigation', oauth?.via === 'browser',
`via=${oauth?.via} — React Router has no such route`);
for (const [name, literal] of [
['client_id', 'client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22'],
['redirect_uri', 'redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback'],
['response_type', 'response_type=code'],
['code_challenge', 'code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'],
['code_challenge_method', 'code_challenge_method=S256'],
['resource', 'resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp'],
['scope', 'scope=krow.read'],
['state', 'state=vT7nQ2xK_Lp9'],
]) {
record(`2. ${name} preserved exactly`, Boolean(oauth?.path.includes(literal)));
}
record('2. percent-encoding is not rewritten', Boolean(oauth?.path.includes('%2F')),
'%2F must not become /');
record('2. an encoded space survives',
safeReturnTo(q('/oauth/authorize?scope=krow.read%20krow.write'))?.path.includes('%20') === true,
'%20 must not become +');
/* ── 3, 13. Internal admin routes keep router navigation ───────────────── */
const admin = safeReturnTo(q('/admin/candidates?stage=applied'));
record('3. /admin/... is accepted', admin?.path === '/admin/candidates?stage=applied');
record('13. and is marked for router navigation', admin?.via === 'router',
`via=${admin?.via} — must not reload the app`);
record('3. bare /admin is accepted', safeReturnTo(q('/admin'))?.via === 'router');
/* ── 4–10. Hostile and malformed values are refused ────────────────────── */
const refuse = [
['4. external URL', 'https://evil.example'],
['4. external URL with our path', 'https://evil.example/oauth/authorize'],
['4. userinfo trick', 'https://platform.krowforce.com@evil.example/'],
['4. another port on our host', 'https://platform.krowforce.com:8443/admin'],
['5. protocol-relative', '//evil.example'],
['5. protocol-relative with path', '//evil.example/steal'],
['5. triple slash', '///evil.example'],
['6. javascript:', 'javascript:alert(document.cookie)'],
['6. javascript: mixed case', 'JaVaScRiPt:alert(1)'],
['6. tab-obfuscated scheme', 'java\tscript:alert(1)'],
['7. data:', 'data:text/html,<script>alert(1)</script>'],
['8. backslash', '/\\evil.example'],
// These two reach the slash/backslash guard specifically: the path is on
// the allowlist, so only the guard can refuse them. Without them the guard
// is unfalsifiable — removing it leaves every other case still passing,
// which a mutation run showed.
['8. backslash in the query of an allowed path', '/admin/candidates?a=\\evil'],
['8. backslash escape smuggled past the allowlist', '/admin/x?next=/\\evil.example'],
['8. dot-slash-slash', '/.//evil.example'],
['9. /admin/login itself', '/admin/login'],
['9. /admin/login with a query', '/admin/login?returnTo=%2Fadmin'],
['9. bare /login', '/login'],
['10. malformed', 'http://[::1'],
['10. file scheme', 'file:///etc/passwd'],
['10. unrelated backend route', '/oauth/token'],
['10. unrelated app route', '/apply'],
['10. the MCP endpoint', '/mcp'],
];
for (const [name, value] of refuse) {
const got = safeReturnTo(q(value));
record(`${name} is refused`, got === null, got ? `returned ${JSON.stringify(got)}` : '');
}
/* ── 11. Absent or empty falls back safely ─────────────────────────────── */
for (const [name, search] of [
['no query at all', ''],
['other parameters only', '?foo=bar'],
['empty returnTo', '?returnTo='],
]) {
record(`11. ${name} returns null`, safeReturnTo(search) === null);
}
} finally {
await server.close();
}
const failed = results.filter((r) => !r.pass).length;
console.log(`\n${results.length - failed} passed, ${failed} failed\n`);
process.exit(failed === 0 ? 0 : 1);

View File

@@ -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 ──');
@@ -7976,8 +8018,30 @@ console.log('\n── Candidates vs Talent Pool ──');
return html.slice(0, at) + html.slice(i);
};
record('the layout controls are present and separable',
now.includes('<div data-ui-controls') && stripControls(now).indexOf('data-ui-controls') === -1);
/**
* A page at rest draws no layout controls at all.
*
* This used to assert the opposite — that the controls were PRESENT — back
* when they led with a `Customise layout` button that was on screen whether
* or not anybody was arranging anything. That button is gone: Owliver is the
* only way to propose a layout change now, and what is left is the bar that
* lets a person accept or reject one. With nothing proposed and nothing
* saved there is nothing to accept, so the component renders nothing.
*
* `stripControls` is kept and still exercised. The bar does appear once a
* change is being previewed, and the comparison below must stay able to lift
* it out when it does.
*/
record('a page at rest draws no layout controls',
!now.includes('<div data-ui-controls'));
record('layout controls stay separable when they are drawn', (() => {
const withBar = `<main><p>kept</p><div data-ui-controls="editor"><div><span>Apply</span></div></div><p>also kept</p></main>`;
const stripped = stripControls(withBar);
return !stripped.includes('data-ui-controls')
&& stripped.includes('<p>kept</p>')
&& stripped.includes('<p>also kept</p>');
})());
now = stripControls(now);
@@ -8308,6 +8372,92 @@ console.log('\n── Candidates vs Talent Pool ──');
return r.ok && ids.indexOf('timeline') < ids.indexOf('activity-privileged-notice');
})());
/* ── Where "to the top" is allowed to be said ──────────────────────────── */
/**
* The destination phrasings, because the missing ones read as a broken feature.
*
* Reported from production: "show the pipeline move to top" was answered "I
* could not find that on this page." The target resolved perfectly well — the
* refusal came from `planMove`, which knew `to the top` and did not know
* `to top`, and a move with no destination falls through to `unknown`. The
* user cannot tell that apart from the section not existing.
*/
const movesFirst = (q) => {
const m = ask(q);
if (m?.kind !== 'plan' || m.op.op !== 'reorder') return false;
return m.op.order[0] === 'timeline';
};
const movesLast = (q) => {
const m = ask(q);
if (m?.kind !== 'plan' || m.op.op !== 'reorder') return false;
return m.op.order[m.op.order.length - 1] === 'timeline';
};
for (const q of [
'move timeline to top',
'move timeline to the top',
'move the timeline to the very top',
'move timeline up',
'move the timeline first',
'put the timeline at the top',
'show the timeline move to top',
]) {
record(`"${q}" → timeline first`, movesFirst(q));
}
for (const q of [
'move timeline to bottom',
'move timeline to the bottom',
'move the timeline to the end',
'move timeline down',
'move the timeline last',
]) {
record(`"${q}" → timeline last`, movesLast(q));
}
/**
* A destination word inside a section's NAME is not a destination.
*
* `over` lives inside `coverage`, `end` inside `trends`. Matched as
* substrings, "move coverage trends to the bottom" satisfied both the top
* reading and the bottom one — and the top is tested first, so it moved the
* opposite way from the one asked for. Whole-word matching is what fixed it;
* this is the case that proves it.
*/
const coverageCase = (() => {
const placed = opsMod4.applyOperation(tree4, {
op: 'add', parent: null,
node: {
id: 'cov-1', type: 'flow', data: { source: 'candidates.activity' },
props: { title: 'Coverage trends' },
},
}, { registry: reg4 });
if (!placed.ok) return { pass: false, detail: 'fixture could not be placed' };
/* Put it at the TOP first, so "to the bottom" is a real change. Added at
the end, it is already there and the answer is a refusal rather than a
plan — which would pass this check for the wrong reason. */
const atTop = opsMod4.applyOperation(placed.tree, {
op: 'reorder',
parent: null,
order: ['cov-1', ...placed.tree.map((n) => n.id).filter((id) => id !== 'cov-1')],
}, { registry: reg4 });
if (!atTop.ok) return { pass: false, detail: 'fixture could not be placed first' };
const m = intentMod.matchUiEdit('move coverage trends to the bottom',
{ tree: atTop.tree, registry: reg4 });
if (m?.kind !== 'plan' || m.op.op !== 'reorder') {
return { pass: false, detail: JSON.stringify(m) };
}
return {
pass: m.op.order[m.op.order.length - 1] === 'cov-1',
detail: m.op.order.join(', '),
};
})();
record('a section named "Coverage trends" still moves to the bottom',
coverageCase.pass, coverageCase.detail);
record('"Change the hiring activity to a table." → replace with table', (() => {
const m = ask('Change the hiring activity to a table.');
return m?.kind === 'plan' && m.op.op === 'replace' && m.op.target === 'flow-1' && m.op.type === 'table';

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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,
});

View File

@@ -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.

View File

@@ -1,88 +1,61 @@
import React from 'react';
import { RotateCcw, SlidersHorizontal, Undo2 } from 'lucide-react';
import { RotateCcw, Undo2 } from 'lucide-react';
import { Button } from '@/components/ds';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { inspectTree } from '@/lib/ui/inspect';
import { nodeRegistry } from '@/lib/ui/registry';
import { TreePanel } from './TreePanel';
import { NodeInspector } from './NodeInspector';
import { NodePicker } from './NodePicker';
/**
* The visual editor.
* The preview bar for a layout change.
*
* It is a *client* of the UI system, not a second implementation of it. Every
* control it draws ends in one call — `propose(op)` on the editing session —
* with an operation object of exactly the shape Owliver produces for the same
* change. From there the two are indistinguishable: same validation, same
* preview merge, same Apply, same `preferences.uiLayouts`.
* control it draws ends in one call on the editing session, with an operation
* of exactly the shape Owliver produces for the same change. From there the two
* are indistinguishable: same validation, same preview merge, same Apply, same
* `preferences.uiLayouts`.
*
* That is the whole architecture:
* Owliver → operation → propose → validate → preview
* ↓ Apply
* preferences.uiLayouts
*
* editor / Owliver → operation → propose → validate → preview
* ↓ Apply
* preferences.uiLayouts
* WHAT THIS USED TO BE, AND WHY IT IS NOT
*
* There is no page in this file, no component name, and no branch on what a
* node is. What can be done to the selected node comes from its registration;
* what can be added comes from the registry and the closed data vocabulary;
* what it is showing comes from the tree. A page that migrates tomorrow is
* editable tomorrow with nothing here changed.
* There was a `Customise layout` button here, and behind it a tree panel, a
* node inspector and a picker — a visual editor sitting beside Owliver, both
* producing the same operations. It was removed on request: two ways to
* rearrange a page is two things to explain, and the panel was on every admin
* screen whether or not anybody was arranging anything.
*
* Owliver is now the only way to propose a layout change. This component keeps
* the other half of that conversation — a person still has to SEE the change
* and decide. `TreePanel`, `NodeInspector` and `NodePicker` are left in the
* directory, unimported: they are the editor, intact, if it is ever wanted
* back.
*
* Nothing about applying moved. `useAssistant` calls `apply` and `discard` on
* the session directly, so "Apply" typed into the panel works exactly as the
* button does — see the `ui-apply` branch there.
*/
export function UiEditor({ registry = nodeRegistry }) {
export function UiEditor() {
const editing = useUiEditing();
const [open, setOpen] = React.useState(false);
const [selectedId, setSelectedId] = React.useState(null);
/* Rendered only where a page has opted into composition. A page that has not
is not broken; it simply has nothing to arrange. */
if (!editing) return null;
const {
tree, propose, discard, apply, undo, reset,
discard, apply, undo, reset,
previewing, customised, saving, problems, skipped,
/* The page this session belongs to. Handed to the picker so what can be
added here is decided by the registry rather than by the picker being
shown everything that exists. */
page,
} = editing;
const nodes = inspectTree(tree, { registry });
const selected = nodes.find((node) => node.id === selectedId) || null;
/* A node's own container, for the reorder buttons and for the picker. */
const siblings = selected ? nodes.filter((node) => node.parent === selected.parent) : [];
const parent = selected?.container ? selected : nodes.find((n) => n.id === selected?.parent) || null;
/**
* The one door out of this component.
*
* Everything the panels do arrives here as an operation and goes straight to
* the session. Nothing is applied, nothing is stored, and nothing is
* validated locally — `propose` refuses what cannot be kept and the refusal
* is shown below.
*/
const operate = (op) => {
const result = propose(op);
/* A removed node cannot stay selected; a replaced one keeps its id. */
if (result.ok && op.op === 'remove') setSelectedId(null);
};
/* Nothing proposed and nothing saved means nothing to say. Without this the
component drew an empty bar on every page, which is most of what the button
was being blamed for. */
if (!previewing && !customised && !problems.length && !skipped.length) return null;
return (
<div data-ui-controls="editor" className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
size="xs"
variant={open ? 'default' : 'outline'}
shape="rounded"
onClick={() => setOpen((v) => !v)}
>
<SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
Customise layout
</Button>
{/* The preview bar. Unsaved and saved have to be told apart at a
glance, because the whole promise is that nothing is kept until
somebody says so. */}
<div className="flex flex-wrap items-center justify-end gap-2">
{/* Unsaved and saved have to be told apart at a glance, because the
whole promise is that nothing is kept until somebody says so. */}
{(previewing || customised) && (
<div className="flex flex-wrap items-center gap-2">
{previewing && (
@@ -122,25 +95,6 @@ export function UiEditor({ registry = nodeRegistry }) {
{skipped.length} saved change{skipped.length === 1 ? '' : 's'} no longer apply to this page.
</p>
)}
{open && (
<div className="grid gap-3 rounded-xl border border-border bg-surface-subtle p-3 lg:grid-cols-2">
<div className="space-y-3">
<TreePanel tree={tree} selectedId={selectedId} onSelect={setSelectedId} registry={registry} />
<NodePicker tree={tree} parent={parent} page={page} onAdd={operate} registry={registry} />
</div>
<div className="rounded-xl border border-border bg-surface p-3">
{selected
? <NodeInspector node={selected} siblings={siblings} page={page} onOperate={operate} registry={registry} />
: (
<p className="text-caption text-ink-3">
Choose a section on the left to see what can be changed about it.
</p>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -88,7 +88,7 @@ export class UiNodeBoundary extends React.Component<UiNodeBoundaryProps, UiNodeB
{node?.props?.title || 'This section could not be shown'}
</p>
<p className="mt-0.5 text-caption text-ink-4">
It is still on the page and can be hidden or removed from Customise layout.
It is still on the page. Ask Owliver to hide it.
</p>
</section>
);

View File

@@ -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() {
<DropdownMenuItem onClick={() => navigate('/admin/workspace/agents')} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" /> Agent Registry
</DropdownMenuItem>
{/* 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. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<Globe className="mr-2 h-4 w-4" /> Language
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={language} onValueChange={setLanguage}>
{LANGUAGES.map((lang) => (
<DropdownMenuRadioItem
key={lang.tag}
value={lang.tag}
className="cursor-pointer"
>
{lang.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={signOut}

133
src/lib/authReturnTo.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* Where an interrupted flow resumes after sign-in.
*
* Kept in one module because it is security-sensitive and easy to drift: this is
* the only place that decides whether a URL somebody else supplied may be
* navigated to.
*
* WHO SETS ?returnTo=, AND WHY THIS EXISTS
*
* The API does. `GET /oauth/authorize` is the browser leg of the MCP OAuth
* flow, and it needs a signed-in person to show a consent screen to. When
* nobody is signed in it redirects to the configured login path, carrying its
* own path and query so the authorization request survives the round trip:
*
* /admin/login?returnTo=%2Foauth%2Fauthorize%3Fclient_id%3D...%26state%3D...
*
* Without this module the person signs in, lands on the dashboard, and the
* authorization request is gone — the connector can never finish.
*
* WHY AN ALLOWLIST RATHER THAN "ANY SAME-ORIGIN PATH"
*
* Only two kinds of destination are reachable this way, and they are reached
* differently, so naming them is both safer and necessary:
*
* /oauth/authorize a BACKEND route. React Router has no such path, so
* routing to it client-side renders the not-found page and
* the request never reaches the server. It needs a real
* browser navigation.
* /admin/... an in-app route, which is what the existing
* `location.state.from` mechanism already carries. Router
* navigation, exactly as before.
*
* Anything else has no business arriving in this parameter, and the narrower
* rule means a future backend route cannot be reached through here by accident.
*
* THE SAME-ORIGIN CHECK IS NOT ENOUGH ON ITS OWN
*
* Values like `/.//evil.example` and `/\evil.example` resolve same-origin and
* then normalise to a protocol-relative `//evil.example` when assigned to
* location — an open redirect through a check that appeared to pass. So the
* resolved path must also begin with exactly one slash and contain no
* backslash. The allowlist below would catch these anyway; both are kept
* because each is load-bearing on its own, and a later edit that loosens the
* allowlist must not silently lose the other.
*
* WHY THE QUERY STRING IS PASSED THROUGH UNTOUCHED
*
* `url.pathname + url.search` is the original text, byte for byte. Nothing here
* reads, rewrites or re-serialises the parameters, and that is deliberate:
* touching `url.searchParams` at all makes the browser re-encode the whole
* query, which can turn `%20` into `+` inside `scope`, or re-spell the
* percent-encoding of `redirect_uri` and `resource`. Every one of those is a
* value the authorization server compares EXACTLY — `code_challenge` against
* the verifier, `redirect_uri` against the registered URI, `state` against what
* the client sent. A re-encoded query is a different query, and the failure
* would surface much later as a mismatched PKCE challenge.
*/
/** How the destination has to be reached. */
export type ReturnVia = 'browser' | 'router';
export interface ReturnTarget {
/** A path on this origin, with its query preserved exactly. */
path: string;
/**
* 'browser' for a backend route, which must bypass React Router.
* 'router' for an in-app route, which must not reload the page.
*/
via: ReturnVia;
}
/** The backend route the OAuth browser leg returns to. */
const OAUTH_AUTHORIZE_PATH = '/oauth/authorize';
/** The login itself, which would sign a person in and show them the login. */
const LOGIN_PATH = '/admin/login';
/** The in-app console. Everything under it is a React Router destination. */
const ADMIN_PREFIX = '/admin';
/**
* Resolve a `returnTo` query parameter to a safe destination.
*
* @param search The query string to read, defaulting to the document's. The
* login page passes the router's value explicitly so this never reaches for a
* global it does not need.
* @returns The destination and how to reach it, or null when there is nothing
* safe to return to — in which case the caller keeps its existing behaviour.
*/
export function safeReturnTo(search?: string): ReturnTarget | null {
if (typeof window === 'undefined') return null;
const raw = new URLSearchParams(search ?? window.location.search).get('returnTo');
if (!raw) return null;
let url: URL;
try {
url = new URL(raw, window.location.origin);
} catch {
// Malformed. Nothing safe to do with it.
return null;
}
// Cross-origin, and anything carrying a scheme of its own — javascript:,
// data:, https://evil.example — fails here: the resolved origin is not ours.
if (url.origin !== window.location.origin) return null;
const path = url.pathname + url.search;
// Exactly one leading slash, no backslash. See the note above on why the
// origin check does not cover this. The backslash test also reaches values
// the allowlist below would have accepted — a backslash survives unencoded
// into `search`, so `/admin/x?next=/\evil.example` is on the allowlist and
// refused only here. The cost is a legitimate `state` containing a raw
// backslash being refused too; that fails closed, landing the person on the
// dashboard rather than anywhere an attacker chose.
if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\')) return null;
if (url.pathname === OAUTH_AUTHORIZE_PATH) {
return { path, via: 'browser' };
}
// The login is refused before the admin prefix is considered, because it sits
// underneath it.
if (url.pathname === LOGIN_PATH) return null;
if (url.pathname === ADMIN_PREFIX || url.pathname.startsWith(ADMIN_PREFIX + '/')) {
return { path, via: 'router' };
}
// Everything else: not a destination this parameter is for.
return null;
}

View File

@@ -37,6 +37,22 @@ const canon = (value) => String(value ?? '')
const has = (text, ...words) => words.some((w) => text.includes(canon(w)));
/**
* Whole-word match, for words that live inside longer ones.
*
* `has` is a substring test. That is right for phrases and wrong for short
* words: `over` is inside `coverage` and `overtime`, `end` is inside `trends`
* and `calendar`, `up` is inside `group`. Read as substrings, "move coverage to
* the bottom" matched both a top reading and a bottom one — and the top is
* tested first, so it moved the opposite way from the one asked for.
*
* Single words only. A phrase is unambiguous as a substring and stays on `has`.
*/
const hasWord = (text, ...words) => {
const tokens = new Set(String(text ?? '').split(' '));
return words.some((w) => tokens.has(canon(w)));
};
/**
* The verbs, as data.
*
@@ -443,8 +459,27 @@ function planUnhide(text, tree, registry) {
function planMove(text, tree, registry) {
const roots = tree.map((node) => describeNode(node, { registry }));
const before = has(text, 'above', 'before', 'over', 'on top of', 'to the top', 'first');
const after = has(text, 'below', 'under', 'beneath', 'after', 'to the bottom', 'last', 'end');
/**
* Where it should end up.
*
* The phrase list is long because people say this a dozen ways and the ones
* that were missing were the short ones: "move it to top" was refused while
* "move it to the top" worked, which reads as the feature being broken rather
* than as a phrasing it does not know. A destination is the whole of what
* this function needs, so failing to recognise one costs the entire request.
*
* Short words go through `hasWord` — see its note. `top` and `bottom` alone
* are enough on their own here: by the time a sentence has reached planMove
* it is already a move, and a bare "top" in a move is a destination.
*/
const before = has(text, 'on top of', 'to the top', 'to top', 'to the very top',
'at the top', 'to the start', 'to the front', 'right to the top')
|| hasWord(text, 'above', 'before', 'over', 'top', 'topmost', 'first', 'up', 'upward', 'upwards');
const after = has(text, 'to the bottom', 'to bottom', 'to the very bottom',
'at the bottom', 'to the end', 'to the back', 'right to the bottom')
|| hasWord(text, 'below', 'under', 'underneath', 'beneath', 'after', 'bottom',
'last', 'end', 'down', 'downward', 'downwards');
/* Split on the positional word so the two halves name two different nodes:
"move timeline above the notice" is a subject and a reference. */

View File

@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import { KROW_LOGO_URL } from '@/assets/brand';
import { Checkbox } from '@/components/ds';
import { useAuth } from '@/lib/AuthContext';
import { safeReturnTo } from '@/lib/authReturnTo';
const DEMO_EMAIL = 'demo@krow.app';
@@ -26,6 +27,24 @@ export default function AdminLogin() {
const location = useLocation();
const { login } = useAuth();
/* Two kinds of "where was I going", and they are not interchangeable.
A ?returnTo= in the QUERY is put there by the API, not by this app. The
OAuth authorization endpoint redirects here when nobody is signed in,
carrying its own path and query so the authorization request survives the
round trip. It names a route on the BACKEND (/oauth/authorize), which React
Router does not have and must not be given — routing to it client-side
renders the not-found page and the connector never finishes. So it is
followed with a real navigation, which safeReturnTo reports as `browser`.
`location.state.from` is the in-app case: a guard bounced someone off a
page in this bundle (ProtectedRoute and AdminRoute both set it). That is a
router destination and stays one, unchanged.
The query wins when both exist. It is the more specific instruction, and it
is the one the person is actually in the middle of. */
const fromQuery = safeReturnTo(location.search);
const from = location.state?.from;
const returnTo = typeof from === 'string' && from.startsWith('/admin') && from !== '/admin/login'
? from
@@ -79,7 +98,13 @@ export default function AdminLogin() {
}
setStatus('success');
setTimeout(() => navigate(returnTo, { replace: true }), 320);
setTimeout(() => {
// `replace`, not `assign`: the login should not sit in history between
// the authorization request and the consent screen, or Back from consent
// returns to a login the person has already completed.
if (fromQuery?.via === 'browser') window.location.replace(fromQuery.path);
else navigate(fromQuery?.path ?? returnTo, { replace: true });
}, 320);
};
const busy = status !== 'idle';