Files
explore/ui/snippets/searchBar/SearchBarAssistant.tsx
T
zeekayandHanzo Dev a3caebadf2 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]>
2026-08-06 19:17:01 -07:00

145 lines
5.8 KiB
TypeScript

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