mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
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]>
145 lines
5.8 KiB
TypeScript
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);
|