search: the icon sat on the placeholder, and validators counted one L1 of four

The magnifying glass was drawn over the leading "S" of the placeholder. The
cause is not the search bar: @luxfi/ui's InputGroup measures its adornments
correctly and then throws the measurement away, because the clone is guarded on
`displayName === 'FieldInput'` — a component that does not exist in the package
— and the props it would have set (`ps`/`pe`) are Chakra style props that the
Tailwind/gui Input spreads onto the DOM and ignores. Both halves are leftovers
of the Chakra→Tailwind port, so EVERY InputGroup in the app is unpadded; the
header is just the one you look at every day. Patched at the primitive
(patches/@[email protected]) rather than hand-tuning padding at one call
site: the offset is the measured width of the actual adornment, so it stays
right when the adornment changes, and FilterInput / revoke's SearchInput / the
showcase are fixed by the same change. Measured at 1440x900: the icon overlapped
the text by 24px and the kbd chip by 52px; both are now 0.

The measurement also moves to a layout effect and stops blanking value and
placeholder on first render. That blanking was inert while the guard swallowed
the clone; with the clone live it flips the field from controlled to
uncontrolled on mount, which React warns about and which can eat a keystroke.

ClearButton spread `className` over its own visibility classes, so a caller
passing one clobbered `invisible` — the header showed a clear button on an empty
field. Merged instead, at the component.

Placeholder is now "Search or ask anything", and Cmd/Ctrl-K opens an assistant.
Nothing in this repo, @luxfi/ui or @hanzo/gui ships a command palette or chat
(no cmdk, no palette, one dialog primitive), so this is the smallest thing that
works: the existing Dialog, one global listener, and a server-side proxy that
holds the credential. The key is deliberately not NEXT_PUBLIC_, so a deployment
without one gets a dialog that says exactly that instead of failing silently.
Off unless NEXT_PUBLIC_AI_ASSISTANT_ENABLED is set — Ctrl+K is the browser's
address-bar shortcut and claiming it to show an error would be worse than not
claiming it.

Validators read 5 on a network running 20. useCurrentValidators asks ONE
P-Chain, the one this deployment points at, but Lux, Hanzo, Zoo and Pars are
each sovereign L1s with their own P-Chain and their own validator set — measured
2026-08-06, five each, with four disjoint NodeID sets. The registry gains the
node origin per chain; absence of it is the signal that a chain has no publicly
reachable node (SPC and Osage are in-cluster only), and those are reported
`unavailable` rather than counted as zero. Stake is deliberately NOT summed:
each chain bonds its own currency.

The proxy resolves `?chain=` through the registry and never from the request, so
it cannot be pointed at an arbitrary origin. The new envs are registered with
the envs-validator and documented in ENVS.md — the schema rejects unknown
NEXT_PUBLIC_* variables and runs at container start, so an undeclared one stops
the pod from booting.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-06 19:17:01 -07:00
co-authored by Hanzo Dev
parent 5de0b82848
commit a3caebadf2
21 changed files with 1492 additions and 881 deletions
+16
View File
@@ -71,6 +71,18 @@ export interface ChainEntry {
readonly hostnames: ReadonlyArray<string>;
readonly explorerUrl: string;
readonly apiUrl: string;
/**
* Origin of the chain's own node API, where `<origin>/v1/bc/P` answers
* platform.* — NOT the indexer at `apiUrl`. Every sovereign L1 runs its own
* P-Chain and its own validator set, so this is what makes a validator count
* readable per chain.
*
* Absent means there is no publicly reachable node for that chain (SPC and
* Osage are in-cluster only). Absence is the honest signal: a chain without
* this is reported as unknown rather than counted as zero.
*/
readonly nodeApiUrl?: string;
readonly branding: ChainBranding;
}
@@ -281,6 +293,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
hostnames: [ 'explore.lux.network', 'localhost', '127.0.0.1', '0.0.0.0' ],
explorerUrl: 'https://explore.lux.network',
apiUrl: 'https://api-explore.lux.network',
nodeApiUrl: 'https://api.lux.network',
branding: LUX_BRANDING,
},
{
@@ -292,6 +305,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
hostnames: [ 'explore-zoo.lux.network', 'explore.zoo.network', 'explorer.zoo.network', 'explore.zoo.ngo' ],
explorerUrl: 'https://explore-zoo.lux.network',
apiUrl: 'https://api-explore-zoo.lux.network',
nodeApiUrl: 'https://api.zoo.network',
branding: ZOO_BRANDING,
},
{
@@ -303,6 +317,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
hostnames: [ 'explore-hanzo.lux.network', 'explore.hanzo.network', 'explore.hanzo.ai' ],
explorerUrl: 'https://explore-hanzo.lux.network',
apiUrl: 'https://api-explore-hanzo.lux.network',
nodeApiUrl: 'https://api.hanzo.network',
branding: HANZO_BRANDING,
},
{
@@ -325,6 +340,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
hostnames: [ 'explore-pars.lux.network', 'explore.pars.network', 'explorer.pars.network' ],
explorerUrl: 'https://explore-pars.lux.network',
apiUrl: 'https://api-explore-pars.lux.network',
nodeApiUrl: 'https://api.pars.network',
branding: PARS_BRANDING,
},
{
+26
View File
@@ -0,0 +1,26 @@
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const title = 'AI assistant';
// The browser only learns whether to offer the assistant; the credential that
// makes it answer is server-side (pages/api/ai.ts) and never reaches a bundle.
// Enabled but without a key is a real state, and the dialog says so — see
// SearchBarAssistant — rather than failing silently.
const config: Feature<{ model: string }> = (() => {
if (getEnvValue('NEXT_PUBLIC_AI_ASSISTANT_ENABLED') === 'true') {
return Object.freeze({
title,
isEnabled: true,
model: getEnvValue('NEXT_PUBLIC_AI_ASSISTANT_MODEL') || 'best',
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
+1
View File
@@ -1,5 +1,6 @@
export { default as advancedFilter } from './advancedFilter';
export { default as account } from './account';
export { default as aiAssistant } from './aiAssistant';
export { default as addressVerification } from './addressVerification';
export { default as addressMetadata } from './addressMetadata';
export { default as address3rdPartyWidgets } from './address3rdPartyWidgets';
+3
View File
@@ -16,6 +16,9 @@
"ui/showcases/utils.ts",
"ui/tx/TxExternalTxs.pw.tsx",
"pnpm-lock.yaml",
// Generated by `pnpm patch-commit` — the diff carries a dependency's own
// bundled identifiers, which are not ours to spell.
"patches/**",
".cspell-words.txt"
],
"enableGlobDot": true,
+1
View File
@@ -148,6 +148,7 @@ const schema = yup
.concat(featuresSchemas.accountSchema)
.concat(featuresSchemas.address3rdPartyWidgetsConfigSchema)
.concat(featuresSchemas.adsSchema)
.concat(featuresSchemas.aiAssistantSchema)
.concat(featuresSchemas.apiDocsSchema)
.concat(featuresSchemas.beaconChainSchema)
.concat(featuresSchemas.bridgedTokensSchema)
@@ -0,0 +1,21 @@
import * as yup from 'yup';
// The schema rejects unknown NEXT_PUBLIC_* variables, and the container
// validates at startup — a variable set on a deployment but absent here stops
// the pod from booting. The credential itself (AI_API_KEY) is server-side and
// deliberately not a NEXT_PUBLIC_ variable, so it is not validated here.
export const aiAssistantSchema = yup
.object()
.shape({
NEXT_PUBLIC_AI_ASSISTANT_ENABLED: yup.boolean(),
NEXT_PUBLIC_AI_ASSISTANT_MODEL: yup
.string()
.when('NEXT_PUBLIC_AI_ASSISTANT_ENABLED', {
is: (value: boolean) => value,
then: (schema) => schema,
otherwise: (schema) => schema.max(
-1,
'NEXT_PUBLIC_AI_ASSISTANT_MODEL cannot be used if NEXT_PUBLIC_AI_ASSISTANT_ENABLED is not set to "true"',
),
}),
});
@@ -1,5 +1,6 @@
export * from './account';
export * from './address3rdPartyWidgets';
export * from './aiAssistant';
export * from './ads';
export * from './apiDocs';
export * from './beaconChain';
+15
View File
@@ -505,6 +505,21 @@ Ads are enabled by default on all self-hosted instances. If you would like to di
&nbsp;
### AI assistant
Opened with `⌘K` / `Ctrl+K` from anywhere in the app. The credential is
server-side and is deliberately **not** a `NEXT_PUBLIC_` variable, so it never
reaches a browser bundle: set `AI_API_KEY` (and optionally `AI_API_URL`,
default `https://api.hanzo.ai/v1/chat/completions`) on the deployment. With the
feature enabled but no key, the dialog says so rather than failing silently.
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_AI_ASSISTANT_ENABLED | `boolean` | Set to true to enable the ⌘K assistant | - | - | `true` | v1.1.23+ |
| NEXT_PUBLIC_AI_ASSISTANT_MODEL | `string` | Model name passed to the AI endpoint | - | `best` | `best` | v1.1.23+ |
&nbsp;
### Beacon chain
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
+2
View File
@@ -2,6 +2,7 @@
export { getPChain, getInfo, getHealth } from './client';
export { useCurrentValidators } from './useCurrentValidators';
export { useNetworkValidators } from './useNetworkValidators';
export { useBlockchains } from './useBlockchains';
export { useNets } from './useNets';
export { useChainHeights } from './useChainHeights';
@@ -19,4 +20,5 @@ export type {
} from './types';
export type { UseCurrentValidatorsResult } from './useCurrentValidators';
export type { NetworkValidators } from './useNetworkValidators';
export type { UseChainHeightsResult } from './useChainHeights';
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
// Pins the honesty contract of the cross-network validator count.
//
// The failure this guards against is arithmetic, not visual: a chain that
// cannot be read contributing 0 to a sum is indistinguishable from a chain
// running no validators, and the explorer has already shipped that bug once
// (see useCurrentValidators, "0 validators / 0 LUX staked" on a live network).
import type * as ReactQuery from '@tanstack/react-query';
import { useQueries } from '@tanstack/react-query';
import type { Mock } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { renderHook } from 'vitest/lib';
import { useNetworkValidators } from './useNetworkValidators';
vi.mock('@tanstack/react-query', async(importOriginal) => ({
...(await importOriginal<typeof ReactQuery>()),
useQueries: vi.fn(),
}));
// Mirrors the mainnet registry: four chains carry a public node, SPC and Osage
// do not. Measured 2026-08-06 against api.{lux,zoo,hanzo,pars}.network/v1/bc/P.
vi.mock('configs/app/chainRegistry', () => ({
getCurrentChain: () => ({ network: 'mainnet' }),
CHAINS: [
{ name: 'C-Chain', chainId: 96369, network: 'mainnet', nodeApiUrl: 'https://api.lux.network' },
{ name: 'Zoo', chainId: 200200, network: 'mainnet', nodeApiUrl: 'https://api.zoo.network' },
{ name: 'Hanzo', chainId: 36963, network: 'mainnet', nodeApiUrl: 'https://api.hanzo.network' },
{ name: 'SPC', chainId: 36911, network: 'mainnet' },
{ name: 'Pars', chainId: 494949, network: 'mainnet', nodeApiUrl: 'https://api.pars.network' },
{ name: 'Osage', chainId: 1872, network: 'mainnet' },
{ name: 'C-Chain', chainId: 96368, network: 'testnet', nodeApiUrl: 'https://api.lux-test.network' },
],
}));
const mockUseQueries = useQueries as Mock<typeof useQueries>;
// renderHook comes from an untyped test helper, so the result is annotated here
// once rather than every call site having to assert it.
function withResults(results: Array<{ data?: number; isLoading?: boolean }>): ReturnType<typeof useNetworkValidators> {
mockUseQueries.mockReturnValue(results.map((r) => ({ data: r.data, isLoading: r.isLoading ?? false })) as never);
return renderHook(() => useNetworkValidators()).result.current as ReturnType<typeof useNetworkValidators>;
}
const ALL_FIVE = [ { data: 5 }, { data: 5 }, { data: 5 }, { data: 5 } ];
describe('useNetworkValidators', () => {
it('queries only the chains that have a node, and only on this network', () => {
withResults(ALL_FIVE);
const queries = mockUseQueries.mock.calls.at(-1)?.[0].queries as Array<{ queryKey: Array<string> }>;
// Four mainnet chains with a node — never SPC/Osage, never the testnet row.
expect(queries.map((q) => q.queryKey[1])).toEqual([ 'C-Chain', 'Zoo', 'Hanzo', 'Pars' ]);
});
it('sums the validator sets of every L1, not just the primary network', () => {
const result = withResults(ALL_FIVE);
// Lux 5 + Zoo 5 + Hanzo 5 + Pars 5. The page showed 5 before this existed.
expect(result.total).toBe(20);
expect(result.isKnown).toBe(true);
expect(result.answeredCount).toBe(4);
expect(result.queriedCount).toBe(4);
});
it('lists a chain with no public node as unavailable rather than zero', () => {
const byName = Object.fromEntries(withResults(ALL_FIVE).networks.map((n) => [ n.name, n ]));
expect(byName.SPC.status).toBe('unavailable');
expect(byName.SPC.validatorCount).toBeUndefined();
expect(byName.Osage.status).toBe('unavailable');
// Pars runs its own P-Chain and answers — it is not an absent chain.
expect(byName.Pars).toMatchObject({ status: 'live', validatorCount: 5 });
});
it('separates a chain that did not answer from one that was never asked', () => {
const byName = Object.fromEntries(
withResults([ { data: 5 }, { data: undefined }, { data: 5 }, { data: 5 } ]).networks.map((n) => [ n.name, n ]),
);
expect(byName.Zoo.status).toBe('unknown');
expect(byName.SPC.status).toBe('unavailable');
});
it('reports a partial total instead of pretending the missing chain has none', () => {
const result = withResults([ { data: 5 }, { data: undefined }, { data: 5 }, { data: 5 } ]);
expect(result.total).toBe(15);
expect(result.answeredCount).toBe(3);
expect(result.queriedCount).toBe(4);
// The caller compares these two to decide whether to label the tile partial.
expect(result.answeredCount).not.toBe(result.queriedCount);
});
it('is not known when nothing answered, so the caller renders a dash', () => {
const result = withResults([ {}, {}, {}, {} ]);
expect(result.isKnown).toBe(false);
// 0 is what the sum degenerates to; isKnown is what stops it being shown.
expect(result.total).toBe(0);
});
});
+113
View File
@@ -0,0 +1,113 @@
// Validator counts across every network in the registry, not just the one this
// explorer is pointed at.
//
// Lux, Hanzo, Zoo and Pars are each a sovereign L1 with its OWN P-Chain and its
// own validator set, so "how many validators" is a sum over chains and there is
// one read per chain. Chains with no publicly reachable node (SPC, Osage — see
// ChainEntry.nodeApiUrl) cannot be read at all, and are reported as unknown
// rather than folded in as zero.
//
// Stake is deliberately NOT summed: each chain bonds its own currency, so
// adding nLUX to nZOO would produce a number that means nothing.
import { useQueries } from '@tanstack/react-query';
import React from 'react';
import type { PChainValidator } from './types';
import { CHAINS, getCurrentChain } from 'configs/app/chainRegistry';
const STALE_TIME_MS = 60_000;
const PRIMARY_NETWORK_ID = '11111111111111111111111111111111LpoYY';
export interface NetworkValidators {
readonly name: string;
readonly chainId: number;
/** Undefined when the chain did not answer — never 0 standing in for it. */
readonly validatorCount: number | undefined;
readonly isLoading: boolean;
/**
* Why the count is undefined. 'unavailable' = no publicly reachable node, so
* it was never asked; 'unknown' = it was asked and did not answer. Callers
* render the difference, because one is a deployment fact and the other is an
* outage.
*/
readonly status: 'live' | 'unknown' | 'unavailable';
}
async function fetchCount(chainName: string): Promise<number> {
const res = await fetch(`/v1/pchain?chain=${ encodeURIComponent(chainName) }`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'platform.getCurrentValidators',
params: { netID: PRIMARY_NETWORK_ID },
id: 1,
}),
});
if (!res.ok) {
throw new Error(`P-chain proxy returned ${ res.status } for ${ chainName }`);
}
const json = await res.json() as { result?: { validators?: ReadonlyArray<PChainValidator> } };
const validators = json.result?.validators;
if (!validators) {
throw new Error(`${ chainName } did not return a validator set`);
}
return validators.length;
}
export function useNetworkValidators() {
// Same network as this deployment: a mainnet explorer must not count testnet
// validators.
const { all, readable } = React.useMemo(() => {
const { network } = getCurrentChain();
const chains = CHAINS.filter((c) => c.network === network);
return { all: chains, readable: chains.filter((c) => c.nodeApiUrl) };
}, []);
const results = useQueries({
queries: readable.map((chain) => ({
queryKey: [ 'pchain:validatorCount', chain.name ],
queryFn: () => fetchCount(chain.name),
staleTime: STALE_TIME_MS,
retry: 1,
})),
});
// Every chain is listed, including the ones that cannot be read: a chain
// missing from the list would read as a chain with no validators.
const networks: ReadonlyArray<NetworkValidators> = all.map((chain) => {
const i = readable.indexOf(chain);
if (i < 0) {
return { name: chain.name, chainId: chain.chainId, validatorCount: undefined, isLoading: false, status: 'unavailable' };
}
const result = results[i];
return {
name: chain.name,
chainId: chain.chainId,
validatorCount: result.data,
isLoading: result.isLoading,
status: result.data !== undefined ? 'live' : 'unknown',
};
});
const answered = networks.filter((n) => n.validatorCount !== undefined);
return {
networks,
total: answered.reduce((sum, n) => sum + (n.validatorCount ?? 0), 0),
/** How many of the queried chains answered — the total means little without it. */
answeredCount: answered.length,
queriedCount: readable.length,
/** A partial total is still a real number, but zero answers is not a zero. */
isKnown: answered.length > 0,
isLoading: results.some((r) => r.isLoading),
};
}
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@luxfi/explore",
"version": "1.1.16",
"version": "1.1.23",
"private": false,
"homepage": "https://github.com/luxfi/explore#readme",
"engines": {
@@ -53,7 +53,7 @@
"@luxfi/points-types": "1.4.2",
"@luxfi/stats-types": "2.11.1",
"@luxfi/tac-operation-lifecycle-types": "1.1.0",
"@luxfi/ui": "7.4.9",
"@luxfi/ui": "7.4.10",
"@luxfi/visualizer-types": "0.2.0",
"@luxfi/zetachain-cctx-types": "1.0.1",
"@metamask/post-message-stream": "^7.0.0",
@@ -263,6 +263,9 @@
"@hanzogui/use-presence": "3.0.1",
"@hanzogui/web": "3.0.2",
"@hanzogui/z-index-stack": "3.0.2"
},
"patchedDependencies": {
"@luxfi/[email protected]": "patches/@[email protected]"
}
},
"packageManager": "[email protected]"
+112
View File
@@ -0,0 +1,112 @@
// Server-side proxy for the ⌘K assistant.
//
// The credential lives here and never reaches a bundle, which is the only
// reason this hop exists — same shape as pages/api/pchain.ts. Hanzo AI
// (api.hanzo.ai) is the one AI stack; the endpoint is OpenAI-compatible.
//
// This route is reachable by anyone who can load the explorer, so it is bounded
// rather than trusted: the request shape, the number of messages, their total
// size and the reply length are all capped before our key is spent.
import type { NextApiRequest, NextApiResponse } from 'next';
const TIMEOUT_MS = 30_000;
const MAX_MESSAGES = 20;
const MAX_CHARS = 8_000;
const MAX_TOKENS = 800;
const API_URL = process.env.AI_API_URL || 'https://api.hanzo.ai/v1/chat/completions';
const MODEL = process.env.NEXT_PUBLIC_AI_ASSISTANT_MODEL || 'best';
const SYSTEM_PROMPT =
'You are the assistant built into the Lux Network block explorer. Answer questions about ' +
'addresses, transactions, blocks, tokens, validators and the Lux chains. Be brief. When you ' +
'do not know something, say so — never invent an address, hash, balance or block number.';
interface Message {
readonly role: 'user' | 'assistant';
readonly content: string;
}
function parseMessages(body: unknown): ReadonlyArray<Message> | undefined {
const messages = (body as { messages?: unknown })?.messages;
if (!Array.isArray(messages) || messages.length === 0 || messages.length > MAX_MESSAGES) {
return undefined;
}
const parsed: Array<Message> = [];
let chars = 0;
for (const m of messages) {
const role = (m as Message)?.role;
const content = (m as Message)?.content;
if ((role !== 'user' && role !== 'assistant') || typeof content !== 'string' || !content) {
return undefined;
}
chars += content.length;
if (chars > MAX_CHARS) {
return undefined;
}
parsed.push({ role, content });
}
return parsed;
}
const handler = async(req: NextApiRequest, res: NextApiResponse): Promise<void> => {
if (req.method !== 'POST') {
res.status(405).json({ error: 'Method not allowed' });
return;
}
const apiKey = process.env.AI_API_KEY;
if (!apiKey) {
// Configured on but not credentialled. Say which, so the dialog can too.
res.status(501).json({ error: 'The assistant is not configured on this deployment (AI_API_KEY is unset).' });
return;
}
const messages = parseMessages(req.body);
if (!messages) {
res.status(400).json({ error: 'Expected { messages: [{ role, content }] } within the size limits' });
return;
}
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${ apiKey }`,
},
body: JSON.stringify({
model: MODEL,
max_tokens: MAX_TOKENS,
messages: [ { role: 'system', content: SYSTEM_PROMPT }, ...messages ],
}),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
// Same guard as the P-chain proxy: a gateway error answers HTML, and
// JSON.parse on that is an opaque crash rather than a usable message.
const raw = await response.text();
let data: { choices?: Array<{ message?: { content?: string } }> };
try {
data = JSON.parse(raw) as { choices?: Array<{ message?: { content?: string } }> };
} catch {
res.status(502).json({ error: `The assistant upstream returned non-JSON (HTTP ${ response.status })` });
return;
}
const content = data.choices?.[0]?.message?.content;
if (!response.ok || !content) {
res.status(502).json({ error: `The assistant upstream returned HTTP ${ response.status } with no answer` });
return;
}
res.status(200).json({ content });
} catch (error) {
res.status(502).json({
error: error instanceof Error ? error.message : 'The assistant request failed',
});
}
};
export default handler;
+17 -1
View File
@@ -3,10 +3,20 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { CHAINS, getCurrentChain } from 'configs/app/chainRegistry';
import { getEnvValue } from 'configs/app/utils';
const TIMEOUT_MS = 10_000;
// `?chain=<name>` reads another network's P-Chain — every sovereign L1 runs its
// own, so a cross-network validator count needs one hop per chain. The name is
// resolved against the registry and never used as a URL: an arbitrary origin
// here would make this endpoint a request forwarder for anything on our network.
function getChainBase(name: string): string | undefined {
const { network } = getCurrentChain();
return CHAINS.find((c) => c.name === name && c.network === network)?.nodeApiUrl;
}
// Derive the node's API origin from the chain RPC URL. The RPC URL points at a
// specific EVM chain (canonical `…/v1/bc/C/rpc`); the P-chain
// lives at `<origin>/v1/bc/P`, so we must reduce to the scheme+host origin and
@@ -31,7 +41,13 @@ const handler = async(req: NextApiRequest, res: NextApiResponse): Promise<void>
return;
}
const base = getApiBase();
const chain = req.query.chain;
if (typeof chain === 'string' && !getChainBase(chain)) {
res.status(404).json({ error: `No node API is registered for chain "${ chain }"` });
return;
}
const base = typeof chain === 'string' ? getChainBase(chain) : getApiBase();
if (!base) {
res.status(500).json({ error: 'NEXT_PUBLIC_NETWORK_RPC_URL not configured' });
return;
+163
View File
@@ -0,0 +1,163 @@
diff --git a/dist/input-group.cjs b/dist/input-group.cjs
index 08c4f4567a919bf51f2d75e67591c4e0285809e9..fc3e318a037600182fec9c3829a5e09a9a79e134 100644
--- a/dist/input-group.cjs
+++ b/dist/input-group.cjs
@@ -31,11 +31,7 @@ var React__namespace = /*#__PURE__*/_interopNamespace(React);
function cn(...inputs) {
return tailwindMerge.twMerge(clsx.clsx(inputs));
}
-function getComponentDisplayName(type) {
- if (typeof type === "string") return void 0;
- if ("displayName" in type) return type.displayName;
- return void 0;
-}
+const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React__namespace.useLayoutEffect : React__namespace.useEffect;
var InputGroup = React__namespace.forwardRef(
function InputGroup2(props, ref) {
const {
@@ -81,7 +77,7 @@ var InputGroup = React__namespace.forwardRef(
}
};
}, [calculateInlinePaddings]);
- React__namespace.useEffect(() => {
+ useIsomorphicLayoutEffect(() => {
calculateInlinePaddings();
const resizeHandler = esToolkit.debounce(calculateInlinePaddings, 300);
const resizeObserver = new ResizeObserver(resizeHandler);
@@ -122,15 +118,18 @@ var InputGroup = React__namespace.forwardRef(
}
),
React__namespace.Children.map(children, (child) => {
- if (getComponentDisplayName(child.type) !== "FieldInput") {
+ if (!React__namespace.isValidElement(child)) {
return child;
}
+ const start = startElement ? startOffset ?? inlinePaddings?.start : void 0;
+ const end = endElement ? endOffset ?? inlinePaddings?.end : void 0;
+ const px = (v) => typeof v === "number" ? `${v}px` : v;
return React__namespace.cloneElement(child, {
- ...startElement && { ps: startOffset ?? (inlinePaddings?.start ? `${inlinePaddings.start}px` : void 0) },
- ...endElement && { pe: endOffset ?? (inlinePaddings?.end ? `${inlinePaddings.end}px` : void 0) },
- // hide input value and placeholder for the first render
- value: inlinePaddings ? child.props.value : void 0,
- placeholder: inlinePaddings ? child.props.placeholder : void 0
+ style: {
+ ...child.props.style,
+ ...start !== void 0 && { paddingInlineStart: px(start) },
+ ...end !== void 0 && { paddingInlineEnd: px(end) }
+ }
});
}),
endElement && /* @__PURE__ */ jsxRuntime.jsx(
diff --git a/dist/input-group.js b/dist/input-group.js
index 71035e813aced180b16bf665d88e7cdbbaa79f90..d56379695166d6b53a12c0b68a255049e0759a63 100644
--- a/dist/input-group.js
+++ b/dist/input-group.js
@@ -9,11 +9,7 @@ import { jsxs, jsx } from 'react/jsx-runtime';
function cn(...inputs) {
return twMerge(clsx(inputs));
}
-function getComponentDisplayName(type) {
- if (typeof type === "string") return void 0;
- if ("displayName" in type) return type.displayName;
- return void 0;
-}
+const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
var InputGroup = React.forwardRef(
function InputGroup2(props, ref) {
const {
@@ -59,7 +55,7 @@ var InputGroup = React.forwardRef(
}
};
}, [calculateInlinePaddings]);
- React.useEffect(() => {
+ useIsomorphicLayoutEffect(() => {
calculateInlinePaddings();
const resizeHandler = debounce(calculateInlinePaddings, 300);
const resizeObserver = new ResizeObserver(resizeHandler);
@@ -100,15 +96,18 @@ var InputGroup = React.forwardRef(
}
),
React.Children.map(children, (child) => {
- if (getComponentDisplayName(child.type) !== "FieldInput") {
+ if (!React.isValidElement(child)) {
return child;
}
+ const start = startElement ? startOffset ?? inlinePaddings?.start : void 0;
+ const end = endElement ? endOffset ?? inlinePaddings?.end : void 0;
+ const px = (v) => typeof v === "number" ? `${v}px` : v;
return React.cloneElement(child, {
- ...startElement && { ps: startOffset ?? (inlinePaddings?.start ? `${inlinePaddings.start}px` : void 0) },
- ...endElement && { pe: endOffset ?? (inlinePaddings?.end ? `${inlinePaddings.end}px` : void 0) },
- // hide input value and placeholder for the first render
- value: inlinePaddings ? child.props.value : void 0,
- placeholder: inlinePaddings ? child.props.placeholder : void 0
+ style: {
+ ...child.props.style,
+ ...start !== void 0 && { paddingInlineStart: px(start) },
+ ...end !== void 0 && { paddingInlineEnd: px(end) }
+ }
});
}),
endElement && /* @__PURE__ */ jsx(
diff --git a/src/input-group.tsx b/src/input-group.tsx
index fc0ed50dad745ae18315571bd44367a5eb86faab..120a95ecfb9c05dba23619288896bf095a2115f8 100644
--- a/src/input-group.tsx
+++ b/src/input-group.tsx
@@ -5,11 +5,11 @@ import { cn } from './utils';
import type { InputProps } from './input';
-function getComponentDisplayName(type: string | React.JSXElementConstructor<unknown>): string | undefined {
- if (typeof type === 'string') return undefined;
- if ('displayName' in type) return type.displayName as string;
- return undefined;
-}
+// The adornment widths must be applied before the browser paints, or the field
+// renders one frame with default padding and the text visibly jumps out from
+// under the icon. useLayoutEffect does that but has no meaning while rendering
+// on the server, where it only warns.
+const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
export interface InputElementProps extends React.HTMLAttributes<HTMLDivElement> {
readonly placement?: 'start' | 'end';
@@ -81,7 +81,7 @@ export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
};
}, [ calculateInlinePaddings ]);
- React.useEffect(() => {
+ useIsomorphicLayoutEffect(() => {
calculateInlinePaddings();
const resizeHandler = debounce(calculateInlinePaddings, 300);
@@ -128,15 +128,24 @@ export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
</div>
) }
{ React.Children.map(children, (child: React.ReactElement<InputProps>) => {
- if (getComponentDisplayName(child.type) !== 'FieldInput') {
+ if (!React.isValidElement(child)) {
return child;
}
+ // The adornments are absolutely positioned over the field, so the
+ // control has to be inset by their measured width or the text starts
+ // underneath them. Written as inline padding rather than the `ps`/`pe`
+ // style props this once carried: those are Chakra's, and Input is now
+ // a Tailwind/gui component that spreads unknown props onto the DOM,
+ // so they applied nothing and the icon sat on the placeholder.
+ const start = startElement ? startOffset ?? inlinePaddings?.start : undefined;
+ const end = endElement ? endOffset ?? inlinePaddings?.end : undefined;
+ const px = (v: string | number) => typeof v === 'number' ? `${ v }px` : v;
return React.cloneElement(child, {
- ...(startElement && { ps: startOffset ?? (inlinePaddings?.start ? `${ inlinePaddings.start }px` : undefined) }),
- ...(endElement && { pe: endOffset ?? (inlinePaddings?.end ? `${ inlinePaddings.end }px` : undefined) }),
- // hide input value and placeholder for the first render
- value: inlinePaddings ? child.props.value : undefined,
- placeholder: inlinePaddings ? child.props.placeholder : undefined,
+ style: {
+ ...child.props.style,
+ ...(start !== undefined && { paddingInlineStart: px(start) }),
+ ...(end !== undefined && { paddingInlineEnd: px(end) }),
+ },
});
}) }
{ endElement && (
+698 -850
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1,19 +1,23 @@
import React from 'react';
import type { CloseButtonProps } from '@luxfi/ui/close-button';
import { CloseButton } from '@luxfi/ui/close-button';
import React from 'react';
import { cn } from 'lib/utils/cn';
export interface ClearButtonProps extends CloseButtonProps {
visible?: boolean;
}
export const ClearButton = ({ disabled, visible = true, ...rest }: ClearButtonProps) => {
// `className` is merged rather than spread over the visibility classes: a caller
// passing one used to clobber `invisible`, which is why the header search showed
// a clear button on an empty field.
export const ClearButton = ({ disabled, visible = true, className, ...rest }: ClearButtonProps) => {
return (
<CloseButton
disabled={ disabled || !visible }
aria-label="Clear"
title="Clear"
className={ visible ? 'opacity-100' : 'opacity-0 invisible' }
className={ cn(visible ? 'opacity-100' : 'opacity-0 invisible', className) }
style={{ color: 'var(--color-text-secondary)' }}
{ ...rest }
/>
+29 -4
View File
@@ -5,7 +5,7 @@ import React from 'react';
import config from 'configs/app';
import { isPrimaryNetworkExplorer } from 'configs/app/chainRegistry';
import { useBlockchains, useChainHeights, useCurrentValidators } from 'lib/api/pchain';
import { useBlockchains, useChainHeights, useCurrentValidators, useNetworkValidators } from 'lib/api/pchain';
import type { PChainBlockchain } from 'lib/api/pchain';
import { cn } from 'lib/utils/cn';
import { Link } from 'toolkit/next/link';
@@ -227,6 +227,7 @@ const SectionCard = ({ title, count, isLoading, action, children }: SectionCardP
const NetworkOverview = () => {
const { stats, isLoading: validatorsLoading, isKnown: hasValidatorData, isError: validatorsError } = useCurrentValidators();
const allValidators = useNetworkValidators();
const { blockchains, isLoading: chainsLoading } = useBlockchains();
const { pChainHeight, cChainHeight, isLoading: heightsLoading } = useChainHeights();
@@ -261,10 +262,14 @@ const NetworkOverview = () => {
'py-4 px-4 gap-x-6 gap-y-3 border border-[var(--color-border-divider)]',
'bg-[var(--color-stats-bg)]',
) }>
{ /* Every L1 in the registry, not just this chain. The breakdown is
in the Validators card below. Stake stays primary-network only:
each chain bonds its own currency and the sum of two currencies
is not a quantity. */ }
<Metric
label="Validators"
value={ hasValidatorData ? String(stats.validatorCount) : '\u2014' }
isLoading={ validatorsLoading }
label={ allValidators.answeredCount === allValidators.queriedCount ? 'Validators' : 'Validators (partial)' }
value={ allValidators.isKnown ? String(allValidators.total) : '\u2014' }
isLoading={ allValidators.isLoading }
/>
<div className="w-px h-7 hidden md:block bg-[var(--color-border-divider)]"/>
<Metric
@@ -414,6 +419,26 @@ const NetworkOverview = () => {
<span className="text-2xs text-[var(--color-text-secondary)]">Delegators</span>
</div>
</div>
{ /* Per-L1 breakdown. The strip above sums these; without the
split, a chain that stopped answering just makes the
total quietly smaller. */ }
<div className="flex flex-col gap-1 mt-4 pt-3 border-t border-[var(--color-border-divider)]">
{ allValidators.networks.map((network) => (
<div key={ network.chainId } className="flex items-center justify-between text-xs">
<span className="text-[var(--color-text-secondary)]">{ network.name }</span>
<Skeleton loading={ network.isLoading }>
<span className={ cn(
'font-mono',
network.status === 'live' ?
'text-[var(--color-text-primary)]' :
'text-[var(--color-text-secondary)]',
) }>
{ network.status === 'live' ? network.validatorCount : network.status }
</span>
</Skeleton>
</div>
)) }
</div>
<div className="flex justify-center mt-4">
<Link href="/validators" className="text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]">
View validators
@@ -0,0 +1,144 @@
import { Button } from '@luxfi/ui/button';
import { DialogBody, DialogContent, DialogHeader, DialogRoot } from '@luxfi/ui/dialog';
import { Input } from '@luxfi/ui/input';
import React from 'react';
import config from 'configs/app';
const feature = config.features.aiAssistant;
// The label is platform-dependent and there is no navigator while the page is
// rendered on the server, so it resolves after mount — the first client render
// has to match the server's or React discards the tree. Undefined means "not
// yet known"; the caller renders nothing rather than guessing wrong and
// swapping the key name under the reader.
export function useAssistantShortcut(): string | undefined {
const [ label, setLabel ] = React.useState<string>();
React.useEffect(() => {
setLabel(/Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘K' : 'Ctrl K');
}, []);
return feature.isEnabled ? label : undefined;
}
// Mounted once, next to the search bar. The search field advertises the
// shortcut; this owns it, so there is one listener no matter how many search
// inputs a page renders.
interface Message {
readonly role: 'user' | 'assistant';
readonly content: string;
}
const SearchBarAssistant = () => {
const [ open, setOpen ] = React.useState(false);
const [ messages, setMessages ] = React.useState<ReadonlyArray<Message>>([]);
const [ value, setValue ] = React.useState('');
const [ isSending, setIsSending ] = React.useState(false);
const [ error, setError ] = React.useState<string>();
React.useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey)) {
return;
}
// Ctrl+K focuses the address bar in Chrome and Firefox; claim it only
// while the assistant can actually answer.
event.preventDefault();
setOpen((prev) => !prev);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
const onOpenChange = React.useCallback(({ open: next }: { open: boolean }) => setOpen(next), []);
// The dialog parks focus on its close button, where a space keypress closes
// it again — so the field claims focus once it is on screen. autoFocus is not
// enough: the dialog's own focus pass runs after mount.
const inputRef = React.useCallback((node: HTMLInputElement | null) => {
node && requestAnimationFrame(() => node.focus());
}, []);
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => setValue(event.target.value), []);
const send = React.useCallback(async(event: React.FormEvent) => {
event.preventDefault();
const question = value.trim();
if (!question || isSending) {
return;
}
const next = [ ...messages, { role: 'user' as const, content: question } ];
setMessages(next);
setValue('');
setError(undefined);
setIsSending(true);
try {
const res = await fetch('/v1/ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: next }),
});
const json = await res.json() as { content?: string; error?: string };
if (!res.ok || !json.content) {
setError(json.error ?? `The assistant returned HTTP ${ res.status }`);
return;
}
setMessages([ ...next, { role: 'assistant', content: json.content } ]);
} catch (e) {
setError(e instanceof Error ? e.message : 'The assistant could not be reached');
} finally {
setIsSending(false);
}
}, [ value, isSending, messages ]);
if (!feature.isEnabled) {
return null;
}
// A plain size, not a breakpoint map: `full` carries min-h-dvh and
// rounded-none, and sizeClasses only prefixes the `lg` half, so those two
// would leak into every width and the panel becomes a full-height sheet.
return (
<DialogRoot open={ open } onOpenChange={ onOpenChange } size="md">
<DialogContent data-testid="ai-chat">
<DialogHeader>Ask anything</DialogHeader>
<DialogBody>
<div className="flex flex-col gap-3 max-h-[50vh] overflow-y-auto mb-3">
{ messages.map((message, index) => (
<div
key={ index }
className={ message.role === 'user' ?
'self-end max-w-[85%] rounded-lg px-3 py-2 text-sm bg-[var(--color-selected-control-bg)] text-[var(--color-selected-control-text)]' :
'self-start max-w-[85%] text-sm text-[var(--color-text-primary)] whitespace-pre-wrap' }
>
{ message.content }
</div>
)) }
{ isSending && <span className="self-start text-sm text-[var(--color-text-secondary)]">Thinking…</span> }
{ error && <span className="self-start text-sm text-[var(--color-text-error)]">{ error }</span> }
</div>
<form onSubmit={ send } className="flex gap-2">
{ /* Input is a gui component that sizes itself to 100% and ignores
flex utilities, so the flex item is this wrapper, not the field. */ }
<div className="flex-1 min-w-0">
<Input
size="md"
ref={ inputRef }
value={ value }
onChange={ handleChange }
placeholder="Ask about an address, transaction or block"
className="border border-solid border-[var(--color-input-border)] rounded-lg"
/>
</div>
{ /* gui's Button lays its children out itself and warns on a bare text node */ }
<Button type="submit" size="md" loading={ isSending } className="shrink-0"><span>Send</span></Button>
</form>
</DialogBody>
</DialogContent>
</DialogRoot>
);
};
export default React.memo(SearchBarAssistant);
+10 -20
View File
@@ -1,14 +1,12 @@
import { Input } from '@luxfi/ui/input';
import { InputGroup } from '@luxfi/ui/input-group';
import React from 'react';
import type { ChangeEvent, FormEvent, FocusEvent } from 'react';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import { Input } from '@luxfi/ui/input';
import { InputGroup } from '@luxfi/ui/input-group';
import { ClearButton } from 'toolkit/components/buttons/ClearButton';
import IconSvg from 'ui/shared/IconSvg';
const nameServicesFeature = config.features.nameServices;
import { useAssistantShortcut } from 'ui/snippets/searchBar/SearchBarAssistant';
interface Props extends Omit<React.HTMLAttributes<HTMLFormElement>, 'onChange'> {
onChange?: (value: string) => void;
@@ -37,6 +35,7 @@ const SearchBarInput = (
const innerRef = React.useRef<HTMLFormElement>(null);
React.useImperativeHandle(ref, () => innerRef.current as HTMLFormElement, []);
const isMobile = useIsMobile();
const shortcut = useAssistantShortcut();
const handleChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
onChange?.(event.target.value);
@@ -77,30 +76,20 @@ const SearchBarInput = (
};
}, [ handleKeyPress ]);
const getPlaceholder = () => {
const clusterText = nameServicesFeature.isEnabled && nameServicesFeature.clusters.isEnabled ? ' / cluster ' : '';
return `Search by address / txn hash / block / token${ clusterText }/... `;
};
const startElement = (
<IconSvg
name="search"
className="w-5 h-5 mx-2"
/>
);
const startElement = <IconSvg name="search" className="w-5 h-5"/>;
const endElement = (
<>
<ClearButton onClick={ onClear } visible={ Boolean(value?.length) } className="mx-2"/>
{ !isMobile && (
{ !isMobile && shortcut && (
<div
className={
'flex items-center justify-center size-5 mr-2 rounded-sm' +
'flex items-center justify-center h-5 px-1.5 mr-2 rounded-sm whitespace-nowrap' +
' border border-solid border-[var(--color-icon-secondary)]' +
' text-[var(--color-icon-secondary)] text-xs font-medium'
}
>
/
{ shortcut }
</div>
) }
</>
@@ -122,11 +111,12 @@ const SearchBarInput = (
>
<InputGroup
startElement={ startElement }
startElementProps={{ className: 'px-2' }}
endElement={ endElement }
>
<Input
size={ isHeroBanner ? 'md' : 'sm' }
placeholder={ getPlaceholder() }
placeholder="Search or ask anything"
value={ value }
onChange={ handleChange }
onFocus={ onFocus }
+2
View File
@@ -8,6 +8,7 @@ import { getCurrentChain, isChainSelectorEnabled, isNetworkSelectorEnabled } fro
import { cn } from 'lib/utils/cn';
import { Link } from 'toolkit/next/link';
import { CONTENT_MAX_WIDTH } from 'ui/shared/layout/utils';
import SearchBarAssistant from 'ui/snippets/searchBar/SearchBarAssistant';
import SearchBar from 'ui/snippets/searchBar/SearchBarDesktop';
import UserProfileDesktop from 'ui/snippets/user/UserProfileDesktop';
@@ -196,6 +197,7 @@ const TopBar = () => {
<div className="flex-1 mx-3 hidden lg:block max-w-[480px] min-w-[180px]">
<SearchBar isHeroBanner={ false }/>
</div>
<SearchBarAssistant/>
{ /* -- Spacer -- */ }
<div className="flex-1 block lg:hidden"/>