- {/* Collapsible sidebar - a 64px icon rail when collapsed, 280px expanded. - Plain flex with an animated width (not a resizable panel), so toggling - never remounts the content area and re-fetches the current page. */} + {/* Animated dot-grid pixel background behind all in-app pages */} + +
+ + {/* Collapsible sidebar */} -
- {/* Breadcrumb header */} -
+
+ {/* Breadcrumb header */} +
{!isHome && (
-
+
+ Connecting to local server...
diff --git a/apps/web/src/components/ui/animated-toast-stack.tsx b/apps/web/src/components/ui/animated-toast-stack.tsx new file mode 100644 index 0000000..b425f5c --- /dev/null +++ b/apps/web/src/components/ui/animated-toast-stack.tsx @@ -0,0 +1,430 @@ +// Animated toast stack - adapted from beui.dev/components/motion/animated-toast-stack. +// +// This does NOT replace react-hot-toast, which the rest of the app uses for +// fire-and-forget confirmations ("Copied"). This one is for long-running CLI +// operations: it can morph a single toast loading -> success/error in place and +// hold a failure open with a Retry action. +// +// Only delta from the source: the success colour uses this project's `success` +// token rather than raw emerald. + +import { AlertCircle, Bell, Check, Info, LoaderCircle, type LucideIcon, X } from 'lucide-react'; +import { AnimatePresence, motion, type Transition, useReducedMotion } from 'motion/react'; +import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { EASE_OUT } from '@/lib/ease'; +import { cn } from '@/lib/utils'; + +export type ToastStatus = 'neutral' | 'info' | 'loading' | 'success' | 'error'; +export type ToastPosition = + | 'top-left' + | 'top-center' + | 'top-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right'; + +export type AnimatedToastAction = { + label: ReactNode; + onClick: (toast: AnimatedToast) => void; +}; + +export type AnimatedToast = { + id: string; + title: ReactNode; + description?: ReactNode; + status?: ToastStatus; + icon?: ReactNode; + action?: AnimatedToastAction; + duration?: number; + dismissible?: boolean; + createdAt?: number; +}; + +export type ToastInput = Omit & { id?: string }; + +export type ToastClassNames = { + root?: string; + item?: string; + surface?: string; + iconWrap?: string; + content?: string; + title?: string; + description?: string; + action?: string; + close?: string; + progress?: string; +}; + +export interface AnimatedToastStackProps { + toasts: AnimatedToast[]; + onDismiss?: (id: string) => void; + position?: ToastPosition; + placement?: 'static' | 'fixed' | 'absolute'; + fixed?: boolean; + portal?: boolean; + portalRoot?: Element | null; + maxVisible?: number; + className?: string; + classNames?: ToastClassNames; + icons?: Partial>; + renderToast?: (toast: AnimatedToast) => ReactNode; +} + +export interface UseAnimatedToastStackOptions { + initialToasts?: ToastInput[]; + defaultDuration?: number; + limit?: number; +} + +const STACK_SPRING: Transition = { type: 'spring', stiffness: 420, damping: 34, mass: 0.75 }; +const CONTENT_TRANSITION = { duration: 0.28, ease: EASE_OUT } as const; + +const STATUS_ICON: Record = { + neutral: Bell, + info: Info, + loading: LoaderCircle, + success: Check, + error: AlertCircle, +}; + +const STATUS_CLASS: Record = { + neutral: 'text-muted-foreground bg-primary/[0.05]', + info: 'text-primary bg-primary/10', + loading: 'text-primary bg-primary/10', + success: 'text-success bg-success/10', + error: 'text-destructive bg-destructive/10', +}; + +const POSITION_CLASS: Record = { + 'top-left': 'left-4 top-4', + 'top-center': 'left-1/2 top-4 -translate-x-1/2', + 'top-right': 'right-4 top-4', + 'bottom-left': 'bottom-6 left-4', + 'bottom-center': 'bottom-6 left-1/2 -translate-x-1/2', + 'bottom-right': 'bottom-6 right-4', +}; + +let idSeed = 0; + +function createToast(input: ToastInput, defaultDuration: number): AnimatedToast { + return { + duration: defaultDuration, + dismissible: true, + ...input, + id: input.id ?? `toast-${Date.now()}-${idSeed++}`, + createdAt: Date.now(), + }; +} + +export function useAnimatedToastStack({ + initialToasts = [], + defaultDuration = 4200, + limit, +}: UseAnimatedToastStackOptions = {}) { + const toastTimers = useRef>(new Map()); + const [toasts, setToasts] = useState(() => + initialToasts.map((toast) => createToast(toast, defaultDuration)) + ); + + const dismissToast = useCallback((id: string) => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + const clearToasts = useCallback(() => setToasts([]), []); + + const showToast = useCallback( + (input: ToastInput) => { + const toast = createToast(input, defaultDuration); + setToasts((current) => { + const next = [...current, toast]; + return typeof limit === 'number' ? next.slice(-limit) : next; + }); + return toast.id; + }, + [defaultDuration, limit] + ); + + const updateToast = useCallback((id: string, patch: Partial) => { + setToasts((current) => + current.map((toast) => + toast.id === id + ? { + ...toast, + ...patch, + id, + createdAt: patch.duration === undefined ? toast.createdAt : Date.now(), + } + : toast + ) + ); + }, []); + + useEffect(() => { + const activeIds = new Set(toasts.map((toast) => toast.id)); + + toastTimers.current.forEach((entry, id) => { + if (!activeIds.has(id)) { + window.clearTimeout(entry.timer); + toastTimers.current.delete(id); + } + }); + + toasts.forEach((toast) => { + const duration = toast.duration ?? defaultDuration; + const existing = toastTimers.current.get(toast.id); + + if (duration <= 0) { + if (existing) { + window.clearTimeout(existing.timer); + toastTimers.current.delete(toast.id); + } + return; + } + + const createdAt = toast.createdAt ?? Date.now(); + const signature = `${createdAt}:${duration}`; + if (existing?.signature === signature) return; + if (existing) window.clearTimeout(existing.timer); + + const elapsed = Date.now() - createdAt; + const remaining = Math.max(duration - elapsed, 0); + const timer = window.setTimeout(() => { + toastTimers.current.delete(toast.id); + dismissToast(toast.id); + }, remaining); + + toastTimers.current.set(toast.id, { timer, signature }); + }); + }, [defaultDuration, dismissToast, toasts]); + + useEffect(() => { + const timers = toastTimers.current; + return () => { + timers.forEach((entry) => { + window.clearTimeout(entry.timer); + }); + timers.clear(); + }; + }, []); + + return useMemo( + () => ({ toasts, showToast, updateToast, dismissToast, clearToasts, setToasts }), + [clearToasts, dismissToast, showToast, toasts, updateToast] + ); +} + +export function AnimatedToastStack({ + toasts, + onDismiss, + position = 'bottom-right', + placement, + fixed = false, + portal, + portalRoot, + maxVisible = 4, + className, + classNames, + icons, + renderToast, +}: AnimatedToastStackProps) { + const [portalTarget, setPortalTarget] = useState(null); + const visibleToasts = toasts.slice(-maxVisible); + const isBottom = position.startsWith('bottom'); + const resolvedPlacement = placement ?? (fixed ? 'fixed' : 'static'); + const shouldPortal = portal ?? resolvedPlacement === 'fixed'; + + useEffect(() => { + setPortalTarget(shouldPortal ? (portalRoot ?? document.body) : null); + }, [portalRoot, shouldPortal]); + + const stack = ( +
    + + {visibleToasts.map((toast, index) => ( + + ))} + +
+ ); + + if (shouldPortal && !portalTarget) return null; + if (shouldPortal && portalTarget) return createPortal(stack, portalTarget); + return stack; +} + +const ToastItem = memo(function ToastItem({ + toast, + index, + onDismiss, + classNames, + icons, + renderToast, +}: { + toast: AnimatedToast; + index: number; + onDismiss?: (id: string) => void; + classNames?: ToastClassNames; + icons?: Partial>; + renderToast?: (toast: AnimatedToast) => ReactNode; +}) { + const reduce = useReducedMotion(); + const status = toast.status ?? 'neutral'; + const Icon = STATUS_ICON[status]; + const iconNode = icons?.[status] ?? toast.icon ?? ; + const canDismiss = toast.dismissible !== false && Boolean(onDismiss); + + return ( + { + if (!canDismiss || !onDismiss) return; + if (Math.abs(info.offset.x) > 72 || Math.abs(info.velocity.x) > 520) { + onDismiss(toast.id); + } + }} + className={cn('pointer-events-auto relative will-change-transform', classNames?.item)} + style={{ zIndex: 20 - index }} + > +
+ {renderToast ? ( + renderToast(toast) + ) : ( +
+ + + + {status === 'loading' ? ( + {iconNode} + ) : ( + iconNode + )} + + + + +
+ + +

+ {toast.title} +

+ {toast.description ? ( +

+ {toast.description} +

+ ) : null} +
+
+ + {toast.action ? ( + + ) : null} +
+ + {canDismiss ? ( + + ) : null} +
+ )} +
+
+ ); +}); diff --git a/apps/web/src/components/ui/command-palette.tsx b/apps/web/src/components/ui/command-palette.tsx new file mode 100644 index 0000000..6c6d888 --- /dev/null +++ b/apps/web/src/components/ui/command-palette.tsx @@ -0,0 +1,316 @@ +// Command palette - adapted from beui.dev/components/blocks/command-palette. +// +// Note the app already binds ⌘K globally to the QuickSwitcher (see MainLayout), +// so any instance of this must pick a different `shortcut`. + +import { type LucideIcon, Search } from 'lucide-react'; +import { motion, useReducedMotion } from 'motion/react'; +import { type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { EASE_OUT } from '@/lib/ease'; +import { cn } from '@/lib/utils'; + +export type CommandItem = { + id: string; + label: string; + group?: string; + hint?: string; + keywords?: string[]; + icon?: LucideIcon; + badge?: ReactNode; + disabled?: boolean; + onSelect: () => void; +}; + +export interface CommandPaletteProps { + items: CommandItem[]; + /** Opens with Cmd/Ctrl + this key. Default: "k" */ + shortcut?: string; + placeholder?: string; + emptyMessage?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +function fuzzyMatch(needle: string, hay: string) { + if (!needle) return true; + const n = needle.toLowerCase(); + const h = hay.toLowerCase(); + let i = 0; + for (const ch of h) { + if (ch === n[i]) i++; + if (i === n.length) return true; + } + return false; +} + +// Opened via a keyboard shortcut many times a day - entrance must read as +// instant. Tight spring, even faster exit. +const PANEL_SPRING = { + type: 'spring', + stiffness: 560, + damping: 40, + mass: 0.5, +} as const; + +export function CommandPalette({ + items, + shortcut = 'k', + placeholder = 'Type a command or search…', + emptyMessage = 'No results found.', + open: controlledOpen, + onOpenChange, +}: CommandPaletteProps) { + const [internalOpen, setInternalOpen] = useState(false); + const controlled = controlledOpen !== undefined; + const open = controlled ? controlledOpen : internalOpen; + const setOpen = useCallback( + (v: boolean) => { + if (!controlled) setInternalOpen(v); + onOpenChange?.(v); + }, + [controlled, onOpenChange] + ); + + const [query, setQuery] = useState(''); + const [active, setActive] = useState(0); + // Portal target only exists client-side; render nothing during hydration. + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + const uid = useId(); + const reduce = useReducedMotion(); + const updateQuery = useCallback((value: string) => { + setQuery(value); + setActive(0); + }, []); + const inputRef = useRef(null); + const listRef = useRef(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === shortcut.toLowerCase()) { + e.preventDefault(); + setOpen(!open); + return; + } + if (e.key === 'Escape' && open) { + // Stop here: the app closes/navigates on bare Escape too, and both + // firing would shut the palette *and* bounce you to the dashboard. + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, shortcut, setOpen]); + + useEffect(() => { + if (open) { + updateQuery(''); + setActive(0); + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [open, updateQuery]); + + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + const filtered = useMemo(() => { + if (!query) return items; + return items.filter((it) => { + const haystacks = [it.label, it.group ?? '', ...(it.keywords ?? [])]; + return haystacks.some((h) => fuzzyMatch(query, h)); + }); + }, [items, query]); + + // Reserve the icon column only when at least one item brings an icon, so + // icon-less lists don't render a dead gap before every label. + const hasIcons = useMemo(() => items.some((it) => it.icon), [items]); + + const grouped = useMemo(() => { + const map = new Map(); + filtered.forEach((it) => { + const g = it.group ?? 'Results'; + const groupItems = map.get(g) ?? []; + groupItems.push(it); + map.set(g, groupItems); + }); + return Array.from(map.entries()); + }, [filtered]); + + const runItem = (it: CommandItem | undefined) => { + if (!it || it.disabled) return; + it.onSelect(); + setOpen(false); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActive((a) => Math.min(filtered.length - 1, a + 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActive((a) => Math.max(0, a - 1)); + } else if (e.key === 'Enter') { + e.preventDefault(); + runItem(filtered[active]); + } + }; + + useEffect(() => { + if (!open) return; + const el = listRef.current?.querySelector(`[data-index="${active}"]`); + el?.scrollIntoView({ block: 'nearest' }); + }, [active, open]); + + let cursor = 0; + + if (!mounted) return null; + + // Always-mounted container; pointer events fully disabled when closed so clicks + // pass through to the page. Portaled to so ancestors with transforms, + // filters, or fixed positioning can't trap the overlay in their stacking context. + return createPortal( +
+ setOpen(false)} + className={cn( + 'absolute inset-0 bg-background/40 [backdrop-filter:blur(12px)_saturate(140%)] [-webkit-backdrop-filter:blur(12px)_saturate(140%)]', + open ? 'pointer-events-auto' : 'pointer-events-none' + )} + /> +
+ +
+ + updateQuery(e.target.value)} + placeholder={placeholder} + tabIndex={open ? 0 : -1} + role="combobox" + aria-expanded={open} + aria-controls={`${uid}-list`} + aria-activedescendant={filtered.length > 0 ? `${uid}-opt-${active}` : undefined} + aria-autocomplete="list" + className="h-12 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none" + /> + + ESC + +
+
+ {filtered.length === 0 ? ( +
{emptyMessage}
+ ) : ( + grouped.map(([group, list]) => ( +
+
+ {group} +
+ {list.map((it) => { + const idx = cursor++; + const isActive = idx === active; + const Icon = it.icon; + return ( + + ); + })} +
+ )) + )} +
+
+
+
, + document.body + ); +} diff --git a/apps/web/src/components/ui/copy-for-ai.tsx b/apps/web/src/components/ui/copy-for-ai.tsx new file mode 100644 index 0000000..b6a1d81 --- /dev/null +++ b/apps/web/src/components/ui/copy-for-ai.tsx @@ -0,0 +1,74 @@ +import { Bot, ChevronDown, Code2, FileText, Sparkles } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; + +interface CopyForAiMenuProps { + /** A ready-made natural-language prompt describing the current view. */ + prompt: string; + /** Raw structured data for the current view, if there's something worth exporting as-is. */ + json?: string; + /** A markdown rendering of what's currently visible, if distinct from `json`. */ + markdown?: string; + onCopy: (text: string, label: string) => void; + className?: string; +} + +function openWithQuery(base: string, query: string) { + window.open(`${base}?q=${encodeURIComponent(query)}`, '_blank', 'noopener,noreferrer'); +} + +export function CopyForAiMenu({ prompt, json, markdown, onCopy, className }: CopyForAiMenuProps) { + return ( + + + + + + onCopy(prompt, 'Prompt')}> + + Copy prompt + + {json ? ( + onCopy(json, 'JSON')}> + + Copy as JSON + + ) : null} + {markdown ? ( + onCopy(markdown, 'Markdown')}> + + Copy page as markdown + + ) : null} + + openWithQuery('https://chatgpt.com/', prompt)}> + + Open in ChatGPT + + openWithQuery('https://claude.ai/new', prompt)}> + + Open in Claude + + + + ); +} diff --git a/apps/web/src/components/ui/motion-checkbox.tsx b/apps/web/src/components/ui/motion-checkbox.tsx new file mode 100644 index 0000000..2603f67 --- /dev/null +++ b/apps/web/src/components/ui/motion-checkbox.tsx @@ -0,0 +1,108 @@ +// Animated checkbox - adapted from beui.dev/components/motion/checkbox. +// Uses `motion/react` to match the rest of components/ui (tabs, loader, ...). + +import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; +import { useId } from 'react'; +import { EASE_OUT, SPRING_PRESS } from '@/lib/ease'; +import { cn } from '@/lib/utils'; + +const CHECK_PATH = 'M5 13l4 4L19 7'; +const INDETERMINATE_PATH = 'M6 12h12'; + +export interface CheckboxProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; + indeterminate?: boolean; + label?: string; + className?: string; + id?: string; + 'aria-label'?: string; +} + +export function Checkbox({ + checked, + onCheckedChange, + disabled, + indeterminate, + label, + className, + id: idProp, + 'aria-label': ariaLabel, +}: CheckboxProps) { + const autoId = useId(); + const id = idProp ?? autoId; + const reduce = useReducedMotion(); + const showMark = checked || indeterminate; + const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH; + + return ( + + ); +} diff --git a/apps/web/src/components/ui/motion-select.tsx b/apps/web/src/components/ui/motion-select.tsx new file mode 100644 index 0000000..71cd294 --- /dev/null +++ b/apps/web/src/components/ui/motion-select.tsx @@ -0,0 +1,426 @@ +// Animated select - adapted from beui.dev/components/motion/select. +// +// Two deltas from the source: Tailwind v4's `border-(--token)` shorthand is +// rewritten for this project's v3 setup, and each option is a `div` rather than +// an `li` (the listbox is a `div`, so a bare `li` would be invalid markup - the +// `role="option"` is what assistive tech reads either way). + +import { Check, ChevronDown } from 'lucide-react'; +import { motion, type Transition, useReducedMotion, type Variants } from 'motion/react'; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import { EASE_OUT } from '@/lib/ease'; +import { cn } from '@/lib/utils'; + +const INSTANT_TRANSITION: Transition = { duration: 0 }; + +// Spring with bounce powers the unfold/separation; per-property timings in the +// content choreograph it (see SelectContent). +const CHEVRON_TRANSITION: Transition = { type: 'spring', duration: 0.4, bounce: 0.3 }; + +const LIST_VARIANTS: Variants = { + hidden: {}, + show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } }, +}; +const ITEM_VARIANTS: Variants = { + hidden: { opacity: 0, y: -6, filter: 'blur(3px)' }, + show: { opacity: 1, y: 0, filter: 'blur(0px)' }, +}; + +type Placement = 'bottom' | 'top'; + +interface SelectContextValue { + value: string | undefined; + open: boolean; + setOpen: (open: boolean) => void; + select: (value: string) => void; + register: (value: string, label: string) => void; + unregister: (value: string) => void; + labelFor: (value: string | undefined) => string | undefined; + reduce: boolean; + triggerId: string; + listId: string; + disabled: boolean; + placement: Placement; + setPlacement: (p: Placement) => void; +} + +const SelectContext = createContext(null); + +function useSelectContext(component: string) { + const ctx = useContext(SelectContext); + if (!ctx) throw new Error(`${component} must be used within { @@ -283,7 +350,8 @@ export function CoinMerge() { > {coins.map((coin) => ( ))} @@ -482,7 +550,9 @@ export function CoinMerge() { {mergeResult.success ? ( <> - Merge successful + + Merge successful + ) : ( <> @@ -548,7 +618,11 @@ export function CoinMerge() { {/* Back to Coins */} - diff --git a/apps/web/src/components/CoinSplit/index.tsx b/apps/web/src/components/CoinSplit/index.tsx index 0c7288d..d820678 100644 --- a/apps/web/src/components/CoinSplit/index.tsx +++ b/apps/web/src/components/CoinSplit/index.tsx @@ -1,3 +1,4 @@ +import type { CoinInfo, CoinMetadata, CoinOperationResult } from '@sui-cli-web/shared'; import { AnimatePresence, motion } from 'framer-motion'; import { AlertCircle, @@ -16,9 +17,9 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import * as api from '@/api/client'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { showErrorToast, showSuccessToast } from '@/lib/toast'; import { useAppStore } from '@/stores/useAppStore'; -import type { CoinInfo, CoinMetadata, CoinOperationResult } from '@sui-cli-web/shared'; // Format balance with proper decimals function formatBalance(balance: string, decimals: number): string { @@ -157,9 +158,7 @@ export function CoinSplit() { if (value && !/^\d*\.?\d*$/.test(value)) return; const rawValue = value ? toRawAmount(value, decimals) : ''; - setSplitAmounts( - splitAmounts.map((a) => (a.id === id ? { ...a, value, rawValue } : a)) - ); + setSplitAmounts(splitAmounts.map((a) => (a.id === id ? { ...a, value, rawValue } : a))); setShowPreview(false); setDryRunResult(null); }; @@ -258,6 +257,52 @@ export function CoinSplit() { const isAnyLoading = isLoading || isEstimating || isSplitting; + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + showSuccessToast({ message: `${label} copied` }); + }; + + const aiJson = JSON.stringify( + { + operation: 'split', + coinType: coinTypeParam, + symbol, + decimals, + sourceCoin: coin + ? { + coinObjectId: coin.coinObjectId, + version: coin.version, + balance: formatBalance(coin.balance, decimals), + } + : null, + splitAmounts: splitAmounts + .filter((a) => a.rawValue) + .map((a) => ({ value: a.value, raw: a.rawValue })), + splitTotal: formatBalance(totalSplitAmount.toString(), decimals), + remaining: formatBalance(remainingBalance.toString(), decimals), + }, + null, + 2 + ); + + const aiMarkdown = [ + '# Sui coin split', + '', + `- **Coin type:** ${coinTypeParam}`, + `- **Symbol:** ${symbol}`, + coin ? `- **Source coin:** ${coin.coinObjectId}` : '', + coin ? `- **Source balance:** ${formatBalance(coin.balance, decimals)} ${symbol}` : '', + `- **Split total:** ${formatBalance(totalSplitAmount.toString(), decimals)} ${symbol}`, + `- **Remaining:** ${formatBalance(remainingBalance.toString(), decimals)} ${symbol}`, + '', + '## Split into', + ...splitAmounts.filter((a) => a.value).map((a, i) => `${i + 1}. ${a.value} ${symbol}`), + ] + .filter(Boolean) + .join('\n'); + + const aiPrompt = `I'm splitting a ${symbol} coin on Sui.\n\n${aiMarkdown}\n\nCheck that these split amounts make sense, don't exceed the source balance, and leave enough behind for gas.`; + if (!coinIdParam || !coinTypeParam) { return (
@@ -285,7 +330,17 @@ export function CoinSplit() {

Split Coin

- {symbol} +
+ {symbol} + {coin && ( + + )} +
{isLoading ? ( @@ -293,9 +348,7 @@ export function CoinSplit() {
) : !coin ? ( -
- Coin not found -
+
Coin not found
) : ( <> {/* Source Coin Card */} @@ -354,7 +407,12 @@ export function CoinSplit() { { key: '5equal', label: '5 Equal' }, { key: '10equal', label: '10 Equal' }, ].map(({ key, label }) => ( - ))} @@ -501,7 +559,9 @@ export function CoinSplit() { {splitResult.success ? ( <> - Split successful + + Split successful + ) : ( <> @@ -579,7 +639,11 @@ export function CoinSplit() { {/* Back to Coins */} - diff --git a/apps/web/src/components/CoinTransfer/index.tsx b/apps/web/src/components/CoinTransfer/index.tsx index 3c24dc1..8e43394 100644 --- a/apps/web/src/components/CoinTransfer/index.tsx +++ b/apps/web/src/components/CoinTransfer/index.tsx @@ -1,13 +1,14 @@ -import { useState, useEffect, useRef } from 'react'; -import { useSearchParams, useNavigate } from 'react-router-dom'; -import { useAppStore } from '@/stores/useAppStore'; -import { getApiBaseUrl } from '@/api/client'; -import { Spinner } from '../shared/Spinner'; +import { AlertCircle, ArrowLeft, CheckCircle, ChevronDown, Send, Wallet } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; import toast from 'react-hot-toast'; -import { ArrowLeft, Send, AlertCircle, CheckCircle, ChevronDown, Wallet } from 'lucide-react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { getApiBaseUrl } from '@/api/client'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; +import { useAppStore } from '@/stores/useAppStore'; +import { Spinner } from '../shared/Spinner'; interface CoinMetadata { coinType: string; @@ -231,6 +232,47 @@ export function CoinTransfer() { } }; + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + }; + + const decimals = metadata?.decimals ?? 9; + const symbol = metadata?.symbol ?? 'Coin'; + + const aiJson = JSON.stringify( + { + operation: 'transfer', + coinType: coinTypeParam, + symbol, + coinObjectId: coinIdParam, + balance: formatBalance(coinBalance, decimals), + from: activeAddress?.address ?? null, + to: toAddress || null, + amount: amount || null, + estimatedGas: estimatedGas || null, + }, + null, + 2 + ); + + const aiMarkdown = [ + '# Sui coin transfer', + '', + `- **Coin type:** ${coinTypeParam}`, + `- **Symbol:** ${symbol}`, + `- **Coin object:** ${coinIdParam}`, + `- **Balance:** ${formatBalance(coinBalance, decimals)} ${symbol}`, + `- **From:** ${activeAddress?.alias || activeAddress?.address || 'not connected'}`, + `- **To:** ${toAddress || '(not set)'}`, + `- **Amount:** ${amount ? `${amount} ${symbol}` : '(not set)'}`, + estimatedGas ? `- **Estimated gas:** ${estimatedGas} SUI` : '', + ] + .filter(Boolean) + .join('\n'); + + const aiPrompt = `I'm transferring ${symbol} on Sui.\n\n${aiMarkdown}\n\nSanity-check the recipient address format and that the amount doesn't exceed the coin balance (leaving room for gas).`; + if (!coinIdParam || !coinTypeParam) { return (
@@ -253,19 +295,22 @@ export function CoinTransfer() { return (
{/* Header */} -
- -
-

Transfer {metadata?.symbol || 'Coin'}

-

Send to any address

+
+
+ +
+

Transfer {metadata?.symbol || 'Coin'}

+

Send to any address

+
+
{/* Coin Info */} diff --git a/apps/web/src/components/Dashboard/ActivityHeatmap.tsx b/apps/web/src/components/Dashboard/ActivityHeatmap.tsx deleted file mode 100644 index ad8523d..0000000 --- a/apps/web/src/components/Dashboard/ActivityHeatmap.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { selectHistory, useMoveDevStore } from '@/components/MoveDeploy/hooks/state/useMoveDevState'; -import { cn } from '@/lib/utils'; - -const WEEKS = 12; -const DAYS = 7; - -// 0 = empty, 1-4 = increasing intensity, thresholds scale to the busiest day seen -// so a handful of local runs still reads as visibly "some activity." -const INTENSITY_OPACITY = [0, 0.25, 0.45, 0.65, 0.9]; - -function intensityFor(count: number, max: number): number { - if (count === 0) return 0; - if (max <= 1) return count > 0 ? 4 : 0; - const ratio = count / max; - if (ratio <= 0.25) return 1; - if (ratio <= 0.5) return 2; - if (ratio <= 0.75) return 3; - return 4; -} - -interface ActivityHeatmapProps { - /** Unix ms timestamps of real on-chain transactions for the active wallet (already deduped - * of the synthetic "current balance" anchor point by the caller). */ - onChainTimestamps?: number[]; -} - -export function ActivityHeatmap({ onChainTimestamps = [] }: ActivityHeatmapProps) { - const history = useMoveDevStore(selectHistory); - - const today = new Date(); - today.setHours(0, 0, 0, 0); - const totalDays = WEEKS * DAYS; - const startDate = new Date(today); - // Align the grid so the last column ends on today, Sunday-first rows. - startDate.setDate(today.getDate() - totalDays + 1 + today.getDay()); - - const localByDate = new Map(); - for (const entry of history) { - const key = new Date(entry.timestamp).toISOString().slice(0, 10); - localByDate.set(key, (localByDate.get(key) ?? 0) + 1); - } - - const onChainByDate = new Map(); - for (const ts of onChainTimestamps) { - const key = new Date(ts).toISOString().slice(0, 10); - onChainByDate.set(key, (onChainByDate.get(key) ?? 0) + 1); - } - - const totalsByDate = new Map(); - for (const key of new Set([...localByDate.keys(), ...onChainByDate.keys()])) { - totalsByDate.set(key, (localByDate.get(key) ?? 0) + (onChainByDate.get(key) ?? 0)); - } - - const maxCount = Math.max(0, ...totalsByDate.values()); - - const cells: { date: string; local: number; onChain: number; count: number }[] = []; - for (let i = 0; i < totalDays; i++) { - const d = new Date(startDate); - d.setDate(startDate.getDate() + i); - const key = d.toISOString().slice(0, 10); - cells.push({ - date: key, - local: localByDate.get(key) ?? 0, - onChain: onChainByDate.get(key) ?? 0, - count: totalsByDate.get(key) ?? 0, - }); - } - - const hasAnyActivity = maxCount > 0; - - return ( -
-
- {cells.map((cell) => { - const level = intensityFor(cell.count, maxCount); - const parts = [ - cell.local > 0 ? `${cell.local} local` : null, - cell.onChain > 0 ? `${cell.onChain} on-chain` : null, - ].filter(Boolean); - const label = parts.length > 0 ? parts.join(' · ') : 'no activity'; - return ( -
- ); - })} -
- {!hasAnyActivity && ( -

- No local or on-chain activity in this window yet — build/test/publish/upgrade runs from - Move Studio, and transactions from the active wallet, will fill this in. -

- )} -
- ); -} diff --git a/apps/web/src/components/Dashboard/DashboardGrid.tsx b/apps/web/src/components/Dashboard/DashboardGrid.tsx new file mode 100644 index 0000000..bd3d6f0 --- /dev/null +++ b/apps/web/src/components/Dashboard/DashboardGrid.tsx @@ -0,0 +1,136 @@ +import { GripVertical, X } from 'lucide-react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { Responsive, type Layout, type Layouts } from 'react-grid-layout'; +import { cn } from '@/lib/utils'; +import './grid.css'; + +// 12 cols on desktop, collapsing to fewer as the viewport narrows. RGL clamps any +// item wider than the current cols, so a 12-wide item just spans the full row on +// small screens instead of overflowing. +export const GRID_BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }; +export const GRID_COLS = { lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }; +export const GRID_ROW_HEIGHT = 30; + +export interface DashboardGridItem { + /** Stable id - must match the `i` used in the layout objects. */ + i: string; + node: ReactNode; +} + +export function DashboardGrid({ + items, + layouts, + editing, + onLayoutChange, + onRemove, +}: { + items: DashboardGridItem[]; + layouts: Layouts; + editing: boolean; + /** Omitted (undefined) outside edit mode so auto/breakpoint reflows are never persisted. */ + onLayoutChange?: (current: Layout[], all: Layouts) => void; + /** Remove a widget from the dashboard (shown as an × on each card in edit mode). */ + onRemove?: (id: string) => void; +}) { + const containerRef = useRef(null); + const [width, setWidth] = useState(0); + // True while a card is actively being dragged/resized. During that window we do + // NOT push the layout back into RGL: onLayoutChange fires on every mouse-move, + // and feeding a freshly-rebuilt layout array back mid-drag makes RGL re-sync + // from the prop and snap the card back - which read as "drag doesn't work". We + // let RGL own the layout during the gesture and only persist once it stops. + const interactingRef = useRef(false); + + // Measure the container ourselves instead of react-grid-layout's WidthProvider. + // WidthProvider only re-measures on `window` resize, so when it takes its one + // measurement too early - before Lenis smooth-scroll and the framer-motion page + // transition have settled the layout - it locks in a wrong/tiny width and never + // corrects, collapsing every card. A ResizeObserver re-measures on any container + // size change (mount settle, sidebar collapse, window resize) and self-heals. + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const measure = () => setWidth(el.clientWidth); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + return ( +
+ {/* Render the grid only once a real width is known - a 0-width first paint is + exactly what produced the collapsed layout, so we skip it entirely. */} + {width > 0 && ( + { + interactingRef.current = true; + }} + onResizeStart={() => { + interactingRef.current = true; + }} + onDragStop={() => { + interactingRef.current = false; + }} + onResizeStop={() => { + interactingRef.current = false; + }} + // RGL fires onLayoutChange during the gesture (skipped) and once more + // right after onDragStop/onResizeStop clears the flag (persisted). + onLayoutChange={(current, all) => { + if (!interactingRef.current) onLayoutChange?.(current, all); + }} + useCSSTransforms + > + {items.map((item) => ( +
+
+ {editing && ( + // Visual affordance only - the whole card is draggable now, so + // this just signals "edit mode / grab me" and never blocks the card. +
+ +
+ )} + {editing && onRemove && ( + + )} + {/* Push content below the handle strip only while editing so the handle + never overlaps the card's own header. */} +
{item.node}
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/Dashboard/DitherBarList.tsx b/apps/web/src/components/Dashboard/DitherBarList.tsx new file mode 100644 index 0000000..6513846 --- /dev/null +++ b/apps/web/src/components/Dashboard/DitherBarList.tsx @@ -0,0 +1,72 @@ +import { DitherGradient } from '@/components/dither-kit/gradient'; +import type { DitherColor } from '@/components/dither-kit/palette'; + +// Same fixed order the coin/wallet charts use so a given series keeps its colour. +const BAR_COLORS: DitherColor[] = ['blue', 'green', 'purple', 'orange', 'pink', 'red', 'grey']; + +export interface BarDatum { + label: string; + value: number; + /** Pre-formatted value shown on the right (falls back to the raw number). */ + formatted?: string; + /** Optional per-row colour override; otherwise cycles the palette by index. */ + color?: DitherColor; +} + +/** + * Horizontal dither bar chart - the on-brand way to compare a handful of + * labelled values (coin balances, per-wallet object/package counts, "top N" + * lists). Bars are widthed against the largest value in the set. Built from the + * same DitherGradient fill the wallets table uses, so it reads as one system. + */ +export function DitherBarList({ + data, + emptyMessage = 'Nothing to show yet', + maxRows, +}: { + data: BarDatum[]; + emptyMessage?: string; + maxRows?: number; +}) { + const sorted = [...data].sort((a, b) => b.value - a.value); + const rows = maxRows ? sorted.slice(0, maxRows) : sorted; + const max = rows.reduce((m, d) => Math.max(m, d.value), 0); + + if (rows.length === 0 || max <= 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + return ( +
+ {rows.map((d, i) => { + // Floor visible bars at 4% so a tiny-but-nonzero value still registers. + const pct = d.value > 0 ? Math.max((d.value / max) * 100, 4) : 0; + const color = d.color ?? BAR_COLORS[i % BAR_COLORS.length]; + return ( +
+
+ {d.label} + + {d.formatted ?? d.value.toLocaleString()} + +
+
+ {pct > 0 && ( +
+ +
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/Dashboard/RecentActivity.tsx b/apps/web/src/components/Dashboard/RecentActivity.tsx index 06aa035..9168daf 100644 --- a/apps/web/src/components/Dashboard/RecentActivity.tsx +++ b/apps/web/src/components/Dashboard/RecentActivity.tsx @@ -63,7 +63,11 @@ export function RecentActivity({ onChainHistory = [], activeWalletAlias }: Recen

No local build/deploy activity yet — actions in Move Studio will show up here.

-
@@ -73,8 +77,10 @@ export function RecentActivity({ onChainHistory = [], activeWalletAlias }: Recen const Icon = OPERATION_ICONS[entry.type]; const packageName = entry.packagePath.split('/').filter(Boolean).pop() || entry.packagePath; return ( -
- +
+
+ +
{OPERATION_LABELS[entry.type]} · {packageName} @@ -117,8 +123,10 @@ export function RecentActivity({ onChainHistory = [], activeWalletAlias }: Recen ) : (
{onChainHistory.map((entry) => ( -
- +
+
+ +
Balance changed to {entry.balance.toFixed(4)} SUI diff --git a/apps/web/src/components/Dashboard/grid.css b/apps/web/src/components/Dashboard/grid.css new file mode 100644 index 0000000..f350a33 --- /dev/null +++ b/apps/web/src/components/Dashboard/grid.css @@ -0,0 +1,52 @@ +/* react-grid-layout theming for the customizable dashboard. + Vendor stylesheets first, then our overrides so the drag/resize affordances + match the app's tokens and only appear while the grid is in edit mode. */ +@import 'react-grid-layout/css/styles.css'; +@import 'react-resizable/css/styles.css'; + +/* Smooth position/size transitions when items reflow, but never while the user + is actively dragging/resizing the item itself (RGL adds .react-draggable-dragging / + .resizing on that node) - transitioning then makes the drag feel laggy. */ +.dashboard-grid .react-grid-item { + transition: transform 200ms ease, width 200ms ease, height 200ms ease; +} +.dashboard-grid .react-grid-item.react-draggable-dragging, +.dashboard-grid .react-grid-item.resizing { + transition: none; + z-index: 30; + cursor: grabbing; +} + +/* The drop target preview shown under a dragged item. */ +.dashboard-grid .react-grid-item.react-grid-placeholder { + background: hsl(var(--primary) / 0.18); + border: 1.5px dashed hsl(var(--primary) / 0.5); + border-radius: 0.75rem; + opacity: 1; +} + +/* Edit-mode affordances - hidden entirely when not editing so normal viewing is + clean and nothing is accidentally draggable. */ +.dashboard-grid:not(.is-editing) .react-resizable-handle { + display: none; +} +.dashboard-grid.is-editing .react-grid-item { + cursor: grab; + outline: 1.5px dashed hsl(var(--border)); + outline-offset: 2px; + border-radius: 0.75rem; +} +.dashboard-grid.is-editing .react-grid-item:hover { + outline-color: hsl(var(--primary) / 0.6); +} + +/* Recolor the default SE resize handle (a background-image arrow) to a theme dot. */ +.dashboard-grid.is-editing .react-resizable-handle::after { + border-right-color: hsl(var(--primary) / 0.7); + border-bottom-color: hsl(var(--primary) / 0.7); + width: 8px; + height: 8px; +} +.dashboard-grid .react-resizable-handle { + background-image: none; +} diff --git a/apps/web/src/components/Dashboard/useDashboardConfig.ts b/apps/web/src/components/Dashboard/useDashboardConfig.ts new file mode 100644 index 0000000..6176040 --- /dev/null +++ b/apps/web/src/components/Dashboard/useDashboardConfig.ts @@ -0,0 +1,193 @@ +import { useCallback, useState } from 'react'; +import type { Layout, Layouts } from 'react-grid-layout'; +import { GRID_COLS } from './DashboardGrid'; + +// Persists BOTH which widgets are on the dashboard and how they're arranged. +// Bumped past the layout-only keys (v1-v4) now that the shape includes activeIds. +const STORAGE_KEY = 'dashboard-config-v5'; + +export interface WidgetSize { + w: number; + h: number; + minW: number; + minH: number; +} + +interface DashboardConfig { + activeIds: string[]; + layouts: Layouts; +} + +const BREAKPOINTS = ['lg', 'md', 'sm', 'xs', 'xxs'] as const; + +/** Bottom edge (in grid rows) of a layout, so a newly-added widget can be + * dropped just below everything else instead of overlapping. */ +function bottomOf(layout: Layout[]): number { + return layout.reduce((max, l) => Math.max(max, l.y + l.h), 0); +} + +function widthForBreakpoint(bp: string, size: WidgetSize): number { + const cols = GRID_COLS[bp as keyof typeof GRID_COLS] ?? 12; + // On the narrow breakpoints every card spans the full width (matches the + // stacked defaults); on lg/md keep the widget's intended width. + return cols < 12 ? cols : Math.min(size.w, cols); +} + +function makeEntry(bp: string, id: string, size: WidgetSize, y: number): Layout { + const cols = GRID_COLS[bp as keyof typeof GRID_COLS] ?? 12; + return { + i: id, + x: 0, + y, + w: widthForBreakpoint(bp, size), + h: size.h, + minW: Math.min(size.minW, cols), + minH: size.minH, + }; +} + +/** + * Reconcile a (possibly partial/stale) config against the current widget set: + * - drop active ids the registry no longer knows about, + * - guarantee every active widget has a layout entry at every breakpoint + * (appending missing ones at the bottom), and + * - drop layout entries for widgets that are no longer active. + * This is what stops an async-loaded or newly-added widget from rendering with + * no/garbage geometry. + */ +function reconcile( + config: Partial | null, + defaults: DashboardConfig, + sizeOf: (id: string) => WidgetSize | undefined +): DashboardConfig { + const rawIds = config?.activeIds && config.activeIds.length > 0 ? config.activeIds : defaults.activeIds; + const activeIds = rawIds.filter((id) => sizeOf(id)); + const finalIds = activeIds.length > 0 ? activeIds : defaults.activeIds; + + const layouts: Layouts = {}; + for (const bp of BREAKPOINTS) { + const saved = config?.layouts?.[bp] ?? defaults.layouts[bp] ?? []; + const savedById = new Map(saved.map((l) => [l.i, l])); + const out: Layout[] = []; + for (const id of finalIds) { + const size = sizeOf(id); + if (!size) continue; + const existing = savedById.get(id); + if (existing) { + out.push({ + ...existing, + w: Math.max(existing.w, Math.min(size.minW, GRID_COLS[bp] ?? 12)), + h: Math.max(existing.h, size.minH), + minW: Math.min(size.minW, GRID_COLS[bp] ?? 12), + minH: size.minH, + }); + } else { + out.push(makeEntry(bp, id, size, bottomOf(out))); + } + } + layouts[bp] = out; + } + return { activeIds: finalIds, layouts }; +} + +export function useDashboardConfig(opts: { + defaultIds: string[]; + defaultLayouts: Layouts; + sizeOf: (id: string) => WidgetSize | undefined; +}) { + const { defaultIds, defaultLayouts, sizeOf } = opts; + const defaults: DashboardConfig = { activeIds: defaultIds, layouts: defaultLayouts }; + + const [config, setConfig] = useState(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return reconcile(JSON.parse(raw), defaults, sizeOf); + } catch { + // ignore + } + return reconcile(null, defaults, sizeOf); + }); + + // Persist layout edits (called from the grid on drag/resize stop). + const onLayoutChange = useCallback( + (_current: Layout[], allLayouts: Layouts) => { + setConfig((prev) => { + const next = reconcile({ activeIds: prev.activeIds, layouts: allLayouts }, defaults, sizeOf); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // ignore + } + return next; + }); + }, + // defaults/sizeOf are stable enough (module-level data); intentionally omitted. + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const add = useCallback( + (id: string) => { + setConfig((prev) => { + if (prev.activeIds.includes(id)) return prev; + const size = sizeOf(id); + if (!size) return prev; + const layouts: Layouts = {}; + for (const bp of BREAKPOINTS) { + const arr = prev.layouts[bp] ?? []; + layouts[bp] = [...arr, makeEntry(bp, id, size, bottomOf(arr))]; + } + const next = { activeIds: [...prev.activeIds, id], layouts }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* ignore */ + } + return next; + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const remove = useCallback( + (id: string) => { + setConfig((prev) => { + if (!prev.activeIds.includes(id)) return prev; + const layouts: Layouts = {}; + for (const bp of BREAKPOINTS) { + layouts[bp] = (prev.layouts[bp] ?? []).filter((l) => l.i !== id); + } + const next = { activeIds: prev.activeIds.filter((x) => x !== id), layouts }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* ignore */ + } + return next; + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const reset = useCallback(() => { + const fresh = reconcile(null, defaults, sizeOf); + setConfig(fresh); + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* ignore */ + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + activeIds: config.activeIds, + layouts: config.layouts, + onLayoutChange, + add, + remove, + reset, + }; +} diff --git a/apps/web/src/components/Dashboard/useLenisScrollable.ts b/apps/web/src/components/Dashboard/useLenisScrollable.ts new file mode 100644 index 0000000..cb19136 --- /dev/null +++ b/apps/web/src/components/Dashboard/useLenisScrollable.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; + +/** + * Ref for a scrollable container living inside the Lenis smooth-scroll page. + * + * Lenis intercepts wheel events globally, so a nested `overflow-auto` box can't + * scroll on its own - which is why the dashboard cards (recent activity, tables, + * a resized-small chart) felt "stuck". Lenis honors a `data-lenis-prevent` + * attribute and leaves native scrolling alone on that element. + * + * We toggle the attribute ONLY while the element actually overflows: if it were + * always present, wheel events over a card that fits would be swallowed and the + * page itself would refuse to scroll while the cursor sat over that card. + */ +export function useLenisScrollable() { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const sync = () => { + const overflowing = el.scrollHeight - el.clientHeight > 1; + el.toggleAttribute('data-lenis-prevent', overflowing); + }; + + sync(); + // Re-check when the card is resized (grid drag/resize, window) or its content + // changes (data loads in, sections expand). + const ro = new ResizeObserver(sync); + ro.observe(el); + const mo = new MutationObserver(sync); + mo.observe(el, { childList: true, subtree: true, characterData: true }); + + return () => { + ro.disconnect(); + mo.disconnect(); + }; + }, []); + + return ref; +} diff --git a/apps/web/src/components/DerivedObjectCalculator/index.tsx b/apps/web/src/components/DerivedObjectCalculator/index.tsx index 4ec24b8..3315ecb 100644 --- a/apps/web/src/components/DerivedObjectCalculator/index.tsx +++ b/apps/web/src/components/DerivedObjectCalculator/index.tsx @@ -4,6 +4,7 @@ import toast from 'react-hot-toast'; import { useNavigate } from 'react-router-dom'; import { type DerivedObjectKeyType, deriveObjectAddress } from '@/api/services/derivedObjects'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { Input } from '@/components/ui/input'; import { Select, @@ -55,12 +56,47 @@ export function DerivedObjectCalculator() { const canCompute = parentId.trim().startsWith('0x') && keyValue.trim().length > 0 && !isComputing; + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + }; + + // Copy-for-AI export of the derivation inputs and computed result. + const aiExport = address + ? { + prompt: [ + `On Sui, I derived an object address from a parent object and a typed key.`, + `- Parent object ID: ${parentId.trim()}`, + `- Key type: ${keyType}`, + `- Key value: ${keyValue.trim()}`, + `- Derived object address: ${address}`, + '', + "This uses @mysten/sui's derived_object::derive_address. Explain how this address is computed and how I can use it.", + ].join('\n'), + json: JSON.stringify( + { + parentId: parentId.trim(), + keyType, + keyValue: keyValue.trim(), + derivedAddress: address, + }, + null, + 2 + ), + } + : null; + return (
-
- - Derived Address Calculator +
+
+ + Derived Address Calculator +
+ {aiExport && ( + + )}

Computes a derived object's deterministic address from its parent object ID and key - the diff --git a/apps/web/src/components/DevTools/index.tsx b/apps/web/src/components/DevTools/index.tsx index 7353d7c..8fbc32c 100644 --- a/apps/web/src/components/DevTools/index.tsx +++ b/apps/web/src/components/DevTools/index.tsx @@ -1,35 +1,36 @@ -import { useState, useMemo, useEffect, useCallback } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Label } from '@/components/ui/label'; +import { AnimatePresence, motion } from 'framer-motion'; import { - Code, - FileCode, Activity, - Loader2, - FolderOpen, - PlayCircle, - FileText, - Settings, AlertCircle, - CheckCircle2, AlertTriangle, + CheckCircle2, ChevronDown, + Code, + FileCode, + FileText, + FolderOpen, Lightbulb, + Loader2, + PlayCircle, + Settings, } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; -import { FileBrowser } from '@/components/MoveDeploy/FileBrowser'; +import { useSearchParams } from 'react-router-dom'; import { - runCoverage, disassembleModule, generatePackageSummary, getPackageModules, getPublishedPackages, type PublishedPackageInfo, + runCoverage, } from '@/api/client'; +import { FileBrowser } from '@/components/MoveDeploy/FileBrowser'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; +import { Label } from '@/components/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; // Helper to parse CLI output and separate warnings from actual content interface ParsedOutput { @@ -42,34 +43,39 @@ interface ParsedOutput { // Simplified warning for user-friendly display interface SimplifiedWarning { - id: string; // e.g., "W99001" - title: string; // User-friendly title - description: string; // Simple explanation - suggestion: string; // What to do about it - location?: string; // File:line (simplified) - rawOutput: string; // Original output for advanced users + id: string; // e.g., "W99001" + title: string; // User-friendly title + description: string; // Simple explanation + suggestion: string; // What to do about it + location?: string; // File:line (simplified) + rawOutput: string; // Original output for advanced users } // Map warning codes to user-friendly explanations -const WARNING_EXPLANATIONS: Record = { - 'W99001': { +const WARNING_EXPLANATIONS: Record< + string, + { title: string; description: string; suggestion: string } +> = { + W99001: { title: 'Better return pattern available', - description: 'Your function sends an object directly to the caller. This works, but returning the object instead makes your code more flexible.', - suggestion: 'Consider using "public fun" that returns the object, so callers can decide what to do with it.', + description: + 'Your function sends an object directly to the caller. This works, but returning the object instead makes your code more flexible.', + suggestion: + 'Consider using "public fun" that returns the object, so callers can decide what to do with it.', }, - 'W09001': { + W09001: { title: 'Unused variable', description: 'You declared a variable but never used it.', suggestion: 'Remove the variable or prefix with underscore (_) if intentional.', }, - 'W09002': { + W09002: { title: 'Unused import', description: 'You imported something but never used it.', suggestion: 'Remove the unused import to keep code clean.', }, - 'W09003': { + W09003: { title: 'Unused function', - description: 'You defined a function but it\'s never called.', + description: "You defined a function but it's never called.", suggestion: 'Remove if not needed, or add "public" if it should be accessible.', }, }; @@ -83,7 +89,9 @@ function parseWarningToSimplified(rawWarning: string): SimplifiedWarning { // Extract location (file:line) const locationMatch = rawWarning.match(/┌─\s+([^:]+):(\d+):\d+/); - const location = locationMatch ? `${locationMatch[1].split('/').pop()}:${locationMatch[2]}` : undefined; + const location = locationMatch + ? `${locationMatch[1].split('/').pop()}:${locationMatch[2]}` + : undefined; // Get explanation from our map, or create generic one const explanation = WARNING_EXPLANATIONS[warningCode] || { @@ -122,16 +130,18 @@ function parseCliOutput(output: string): ParsedOutput { // Box drawing characters: ┌ ─ │ └ ├ ╭ ╮ ╯ ╰ const boxChars = /^[\s┌─│└├╭╮╯╰]/; // Also match lines starting with spaces, =, or containing "This warning" - return boxChars.test(line) || - line.startsWith(' ') || - line.startsWith(' ') || - line.startsWith('=') || - line.includes('This warning can be suppressed') || - line.includes('Returning an object') || - line.includes('Transaction sender') || - line.includes('Transfer of an object') || - line.includes('^^^^') || - line.trim() === ''; + return ( + boxChars.test(line) || + line.startsWith(' ') || + line.startsWith(' ') || + line.startsWith('=') || + line.includes('This warning can be suppressed') || + line.includes('Returning an object') || + line.includes('Transaction sender') || + line.includes('Transfer of an object') || + line.includes('^^^^') || + line.trim() === '' + ); }; for (const line of lines) { @@ -253,7 +263,9 @@ export function DevTools() { // File Browser State const [showBrowser, setShowBrowser] = useState(false); - const [browserTarget, setBrowserTarget] = useState<'coverage' | 'disassembly' | 'summary'>('coverage'); + const [browserTarget, setBrowserTarget] = useState<'coverage' | 'disassembly' | 'summary'>( + 'coverage' + ); // Check if module name is required for current coverage mode const moduleNameRequired = coverageMode === 'source' || coverageMode === 'bytecode'; @@ -348,11 +360,7 @@ export function DevTools() { setDisassemblyOutput(''); try { - const data = await disassembleModule( - modulePath.trim(), - showDebug, - showBytecodeMap - ); + const data = await disassembleModule(modulePath.trim(), showDebug, showBytecodeMap); setDisassemblyOutput(data.output); toast.success('Disassembly complete!'); } catch (error: any) { @@ -381,9 +389,7 @@ export function DevTools() { summaryFormat ); setSummaryOutput( - summaryFormat === 'json' - ? JSON.stringify(data.summary, null, 2) - : String(data.summary) + summaryFormat === 'json' ? JSON.stringify(data.summary, null, 2) : String(data.summary) ); toast.success('Summary generated!'); } catch (error: any) { @@ -395,6 +401,89 @@ export function DevTools() { } }; + // Copy-for-AI: assemble the active tool's inputs/outputs into shareable context + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + }; + + const tabLabels: Record = { + coverage: 'Test Coverage', + disassemble: 'Bytecode Disassembly', + summary: 'Package Summary', + }; + + const aiState: Record = + activeTab === 'coverage' + ? { + tool: 'coverage', + packagePath: packagePath.trim() || null, + coverageMode, + moduleName: coverageModuleName.trim() || null, + detectedModules, + output: coverageOutput ? coverageOutput.slice(0, 6000) : null, + } + : activeTab === 'disassemble' + ? { + tool: 'disassemble', + modulePath: modulePath.trim() || null, + showDebug, + showBytecodeMap, + output: disassemblyOutput ? disassemblyOutput.slice(0, 6000) : null, + } + : { + tool: 'summary', + packagePath: summaryPackagePath.trim() || null, + packageId: summaryPackageId.trim() || null, + format: summaryFormat, + output: summaryOutput ? summaryOutput.slice(0, 6000) : null, + }; + + const hasExportable = + activeTab === 'coverage' + ? !!(packagePath.trim() || coverageOutput) + : activeTab === 'disassemble' + ? !!(modulePath.trim() || disassemblyOutput) + : !!(summaryPackagePath.trim() || summaryPackageId.trim() || summaryOutput); + + const aiActiveOutput = + activeTab === 'coverage' + ? coverageOutput + : activeTab === 'disassemble' + ? disassemblyOutput + : summaryOutput; + + const aiJson = JSON.stringify(aiState, null, 2); + + const aiMarkdown = [ + `# Sui Move Dev Tools — ${tabLabels[activeTab]}`, + '', + ...(activeTab === 'coverage' + ? [ + `- **Package path:** ${packagePath.trim() || 'not set'}`, + `- **Mode:** ${coverageMode}`, + coverageModuleName.trim() ? `- **Module:** ${coverageModuleName.trim()}` : null, + ] + : activeTab === 'disassemble' + ? [ + `- **Module path:** ${modulePath.trim() || 'not set'}`, + `- **Show debug:** ${showDebug ? 'yes' : 'no'}`, + `- **Show bytecode map:** ${showBytecodeMap ? 'yes' : 'no'}`, + ] + : [ + `- **Package path:** ${summaryPackagePath.trim() || 'not set'}`, + `- **Package ID:** ${summaryPackageId.trim() || 'not set'}`, + `- **Format:** ${summaryFormat}`, + ]), + aiActiveOutput ? '' : null, + aiActiveOutput ? '## Output' : null, + aiActiveOutput ? '```\n' + aiActiveOutput.slice(0, 6000) + '\n```' : null, + ] + .filter((line) => line !== null) + .join('\n'); + + const aiPrompt = `Here's the output from the Sui Move "${tabLabels[activeTab]}" dev tool:\n\n${aiMarkdown}\n\nHelp me interpret this and suggest concrete next steps (e.g. improving test coverage, understanding the bytecode, or reviewing the package structure).`; + return ( <>

@@ -405,9 +494,14 @@ export function DevTools() {

Dev Tools

- - Coverage · Disassemble · Summary - + {hasExportable && ( + + )}
{/* Main Content */} @@ -417,20 +511,22 @@ export function DevTools() { transition={{ type: 'spring', stiffness: 300, damping: 25, delay: 0.1 }} > - - - + + } className="flex-1"> Coverage - - + } className="flex-1"> Disassemble - - + } className="flex-1"> Summary +

+ {activeTab === 'coverage' && 'Move test coverage report for your package'} + {activeTab === 'disassemble' && 'View compiled bytecode as Move disassembly'} + {activeTab === 'summary' && 'Package structure and module summary'} +

{/* Coverage Tab */} @@ -450,13 +546,18 @@ export function DevTools() { Test Coverage Analysis - Run coverage on Move packages + + Run coverage on Move packages + {/* Package Path */}
-
+ ); +} + +export default DevstackBridge; diff --git a/apps/web/src/components/DynamicFieldExplorer/index.tsx b/apps/web/src/components/DynamicFieldExplorer/index.tsx index c0cdc73..8d325c9 100644 --- a/apps/web/src/components/DynamicFieldExplorer/index.tsx +++ b/apps/web/src/components/DynamicFieldExplorer/index.tsx @@ -1,11 +1,21 @@ -import { useState, useEffect } from 'react'; -import { useSearchParams, useNavigate } from 'react-router-dom'; +import { + ArrowRight, + ChevronDown, + Copy, + ExternalLink, + Eye, + Link2, + RefreshCw, + Search, +} from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import toast from 'react-hot-toast'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { getDynamicFields } from '@/api/client'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { Spinner } from '../shared/Spinner'; -import toast from 'react-hot-toast'; -import { Link2, Eye, Search, Copy, ExternalLink, ChevronDown, RefreshCw, ArrowRight } from 'lucide-react'; interface DynamicField { name: any; @@ -18,7 +28,12 @@ interface DynamicField { } // Parse field key from name object -function parseFieldKey(name: any): { type: string; value: string; displayType: string; displayValue: string } { +function parseFieldKey(name: any): { + type: string; + value: string; + displayType: string; + displayValue: string; +} { if (typeof name === 'object' && name !== null) { const type = name.type || 'unknown'; const value = name.value || JSON.stringify(name); @@ -27,9 +42,10 @@ function parseFieldKey(name: any): { type: string; value: string; displayType: s const displayType = type.split('::').pop() || type; // Shortened value for display - const displayValue = typeof value === 'string' && value.length > 20 - ? `${value.slice(0, 10)}...${value.slice(-8)}` - : String(value); + const displayValue = + typeof value === 'string' && value.length > 20 + ? `${value.slice(0, 10)}...${value.slice(-8)}` + : String(value); return { type, value, displayType, displayValue }; } @@ -37,7 +53,7 @@ function parseFieldKey(name: any): { type: string; value: string; displayType: s type: 'string', value: String(name), displayType: 'string', - displayValue: String(name) + displayValue: String(name), }; } @@ -57,14 +73,54 @@ function getPackageId(fullType: string): string { } // Get type-based styling -function getTypeStyle(objectType: string): { icon: string; colorClass: string; bgClass: string; borderClass: string } { +function getTypeStyle(objectType: string): { + icon: string; + colorClass: string; + bgClass: string; + borderClass: string; +} { const type = objectType.toLowerCase(); - if (type.includes('table')) return { icon: '📊', colorClass: 'text-cyan-400', bgClass: 'bg-cyan-500/20', borderClass: 'border-cyan-500/30' }; - if (type.includes('bag')) return { icon: '🎒', colorClass: 'text-purple-400', bgClass: 'bg-purple-500/20', borderClass: 'border-purple-500/30' }; - if (type.includes('vec') || type.includes('vector')) return { icon: '📋', colorClass: 'text-green-400', bgClass: 'bg-green-500/20', borderClass: 'border-green-500/30' }; - if (type.includes('item') || type.includes('nft') || type.includes('character')) return { icon: '🎮', colorClass: 'text-orange-400', bgClass: 'bg-orange-500/20', borderClass: 'border-orange-500/30' }; - if (type.includes('coin')) return { icon: '🪙', colorClass: 'text-yellow-400', bgClass: 'bg-yellow-500/20', borderClass: 'border-yellow-500/30' }; - return { icon: '📦', colorClass: 'text-blue-400', bgClass: 'bg-blue-500/20', borderClass: 'border-blue-500/30' }; + if (type.includes('table')) + return { + icon: '📊', + colorClass: 'text-cyan-400', + bgClass: 'bg-cyan-500/20', + borderClass: 'border-cyan-500/30', + }; + if (type.includes('bag')) + return { + icon: '🎒', + colorClass: 'text-purple-400', + bgClass: 'bg-purple-500/20', + borderClass: 'border-purple-500/30', + }; + if (type.includes('vec') || type.includes('vector')) + return { + icon: '📋', + colorClass: 'text-green-400', + bgClass: 'bg-green-500/20', + borderClass: 'border-green-500/30', + }; + if (type.includes('item') || type.includes('nft') || type.includes('character')) + return { + icon: '🎮', + colorClass: 'text-orange-400', + bgClass: 'bg-orange-500/20', + borderClass: 'border-orange-500/30', + }; + if (type.includes('coin')) + return { + icon: '🪙', + colorClass: 'text-yellow-400', + bgClass: 'bg-yellow-500/20', + borderClass: 'border-yellow-500/30', + }; + return { + icon: '📦', + colorClass: 'text-blue-400', + bgClass: 'bg-blue-500/20', + borderClass: 'border-blue-500/30', + }; } export function DynamicFieldExplorer() { @@ -99,18 +155,14 @@ export function DynamicFieldExplorer() { setIsLoading(true); try { - const result = await getDynamicFields( - cursor ? queriedObjectId : id, - cursor, - 50 - ); + const result = await getDynamicFields(cursor ? queriedObjectId : id, cursor, 50); if (!cursor) { setFields(result.data); setQueriedObjectId(id); setExpandedFields(new Set()); // Collapse all on new query } else { - setFields(prev => [...prev, ...result.data]); + setFields((prev) => [...prev, ...result.data]); } setHasNextPage(result.hasNextPage); @@ -167,12 +219,67 @@ export function DynamicFieldExplorer() { window.open(`https://suiscan.xyz/testnet/object/${objectId}`, '_blank'); }; + // Copy-for-AI export of the parent object and its dynamic fields (capped so a + // huge collection doesn't produce an unusable prompt). + const aiExport = useMemo(() => { + if (!queriedObjectId || fields.length === 0) return null; + const CAP = 200; + const capped = fields.slice(0, CAP); + const rows = capped.map((f) => { + const key = parseFieldKey(f.name); + return { + keyType: key.type, + keyValue: key.value, + objectId: f.objectId, + objectType: f.objectType, + version: f.version, + }; + }); + const truncatedNote = fields.length > CAP ? ` (showing first ${CAP} of ${fields.length})` : ''; + const prompt = [ + `The Sui object ${queriedObjectId} has ${fields.length} dynamic field${fields.length !== 1 ? 's' : ''}${truncatedNote}.`, + 'Fields (key -> stored object):', + ...rows.map( + (r) => + `- ${getShortTypeName(r.keyType)} ${r.keyValue} -> ${r.objectId} (${getShortTypeName(r.objectType)})` + ), + '', + 'Help me understand what this object stores and how these dynamic fields are used.', + ].join('\n'); + const json = JSON.stringify( + { parentObjectId: queriedObjectId, totalFields: fields.length, fields: rows }, + null, + 2 + ); + const markdown = [ + `# Dynamic Fields of \`${queriedObjectId}\`${truncatedNote}`, + '', + '| Key type | Key value | Stored object | Object type |', + '| --- | --- | --- | --- |', + ...rows.map( + (r) => + `| ${getShortTypeName(r.keyType)} | ${r.keyValue} | \`${r.objectId}\` | ${getShortTypeName(r.objectType)} |` + ), + ].join('\n'); + return { prompt, json, markdown }; + }, [queriedObjectId, fields]); + return (
{/* Header */} -
- -

Dynamic Fields

+
+
+ +

Dynamic Fields

+
+ {aiExport && ( + + )}
{/* Search Input */} @@ -264,7 +371,9 @@ export function DynamicFieldExplorer() { className="flex items-center gap-3 px-3 py-3 cursor-pointer hover:bg-accent/50 transition-colors" onClick={() => toggleExpanded(index)} > -
+
{typeStyle.icon}
@@ -273,9 +382,7 @@ export function DynamicFieldExplorer() { {shortType} - - Field {index + 1} - + Field {index + 1}
{field.objectId.slice(0, 12)}...{field.objectId.slice(-8)} @@ -298,7 +405,9 @@ export function DynamicFieldExplorer() {
Type - {keyInfo.displayType} + + {keyInfo.displayType} +
Value @@ -339,22 +448,30 @@ export function DynamicFieldExplorer() { copyToClipboard(field.objectId, 'Object ID'); }} > - {field.objectId.slice(0, 10)}...{field.objectId.slice(-8)} + + {field.objectId.slice(0, 10)}...{field.objectId.slice(-8)} +
Type - {shortType} + + {shortType} +
Version - {field.version} + + {field.version} +
{getPackageId(field.objectType) && (
Package - {getPackageId(field.objectType)} + + {getPackageId(field.objectType)} +
)}
@@ -438,15 +555,22 @@ export function DynamicFieldExplorer() {
📭
No dynamic fields
-
This object doesn't have any dynamic fields attached.
+
+ This object doesn't have any dynamic fields attached. +
💡 - What are dynamic fields? + + What are dynamic fields? +
-

Dynamic fields let you attach key-value data to objects at runtime without declaring them in the Move struct definition.

+

+ Dynamic fields let you attach key-value data to objects at runtime without + declaring them in the Move struct definition. +

🎮 @@ -480,9 +604,7 @@ export function DynamicFieldExplorer() {
-
- What you'll see -
+
What you'll see
🔑 diff --git a/apps/web/src/components/EnvironmentList/index.tsx b/apps/web/src/components/EnvironmentList/index.tsx index 8250e05..a065273 100644 --- a/apps/web/src/components/EnvironmentList/index.tsx +++ b/apps/web/src/components/EnvironmentList/index.tsx @@ -1,10 +1,11 @@ import { clsx } from 'clsx'; import { Plus, Trash2 } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; import { getChainIdentifier } from '@/api/client'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { useAppStore } from '@/stores/useAppStore'; import { Spinner } from '../shared/Spinner'; @@ -59,6 +60,44 @@ export function EnvironmentList() { return env.alias.toLowerCase().includes(query) || env.rpc.toLowerCase().includes(query); }); + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + }; + + // Copy-for-AI export. Public only: env name, rpc url, active flag - all of + // which are plain network config, no secrets. + const aiExport = useMemo(() => { + const envs = filteredEnvs.map((e) => ({ + name: e.alias, + rpcUrl: e.rpc, + active: e.isActive, + })); + const active = envs.find((e) => e.active); + const prompt = [ + `I'm using the Sui CLI with ${envs.length} configured environment${envs.length !== 1 ? 's' : ''}.`, + active ? `The active environment is "${active.name}" (${active.rpcUrl}).` : '', + chainId + ? `Active chain identifier: ${chainId}${chainNetwork ? ` (${chainNetwork})` : ''}.` + : '', + 'Environments:', + ...envs.map((e) => `- ${e.name}: ${e.rpcUrl}${e.active ? ' [active]' : ''}`), + '', + 'Help me work with these Sui network environments.', + ] + .filter(Boolean) + .join('\n'); + const json = JSON.stringify({ chainId, chainNetwork, environments: envs }, null, 2); + const markdown = [ + '# Sui CLI Environments', + '', + '| Name | RPC URL | Active |', + '| --- | --- | --- |', + ...envs.map((e) => `| ${e.name} | \`${e.rpcUrl}\` | ${e.active ? 'yes' : 'no'} |`), + ].join('\n'); + return { prompt, json, markdown }; + }, [filteredEnvs, chainId, chainNetwork]); + const handleSwitch = async (alias: string) => { try { await switchEnvironment(alias); @@ -113,6 +152,21 @@ export function EnvironmentList() { return (
+ {/* Header */} + {filteredEnvs.length > 0 && ( +
+ + {filteredEnvs.length} environment{filteredEnvs.length !== 1 ? 's' : ''} + + +
+ )} + {/* Chain Identifier Display */} {chainId && (
@@ -177,7 +231,11 @@ export function EnvironmentList() {
) : ( - diff --git a/apps/web/src/components/EventExplorer/index.tsx b/apps/web/src/components/EventExplorer/index.tsx index b5ee44c..b6d5578 100644 --- a/apps/web/src/components/EventExplorer/index.tsx +++ b/apps/web/src/components/EventExplorer/index.tsx @@ -2,33 +2,34 @@ * EventExplorer - Decode and understand Sui events */ -import React, { useState, useCallback, useMemo } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; +import { AnimatePresence, motion } from 'framer-motion'; import { - Search, - Zap, + Activity, + ArrowRightLeft, + BarChart3, + Check, ChevronDown, ChevronRight, - Copy, - Check, - Filter, - Activity, Coins, - ArrowRightLeft, - FileCode, + Copy, Database, - TrendingUp, - Users, - Shield, - Sparkles, ExternalLink, + FileCode, + Filter, Info, Loader2, - BarChart3, + Search, + Shield, + Sparkles, + TrendingUp, + Users, + Zap, } from 'lucide-react'; +import React, { useCallback, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; import { apiClient } from '@/api/client'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; interface ParsedEvent { id: string; @@ -45,10 +46,26 @@ interface ParsedEvent { // Known protocol detection const KNOWN_PROTOCOLS: Record = { - '0x8d97f1cd6ac663735be08d1d2b6d02a159e711586461306ce60a2b7a6a565a9e': { name: 'Pyth Oracle', color: 'text-purple-400', icon: }, - '0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf': { name: 'Wormhole', color: 'text-blue-400', icon: }, - '0xa0eba10b173538c8fecca1dff298e488402cc9ff374f8a12ca7758eebe830b66': { name: 'Cetus DEX', color: 'text-cyan-400', icon: }, - '0x91bfbc386a41afcfd9b2533058d7e915a1d3829089cc268ff4333d54d6339ca1': { name: 'Turbos DEX', color: 'text-green-400', icon: }, + '0x8d97f1cd6ac663735be08d1d2b6d02a159e711586461306ce60a2b7a6a565a9e': { + name: 'Pyth Oracle', + color: 'text-purple-400', + icon: , + }, + '0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf': { + name: 'Wormhole', + color: 'text-blue-400', + icon: , + }, + '0xa0eba10b173538c8fecca1dff298e488402cc9ff374f8a12ca7758eebe830b66': { + name: 'Cetus DEX', + color: 'text-cyan-400', + icon: , + }, + '0x91bfbc386a41afcfd9b2533058d7e915a1d3829089cc268ff4333d54d6339ca1': { + name: 'Turbos DEX', + color: 'text-green-400', + icon: , + }, '0xdee9': { name: 'DeepBook', color: 'text-yellow-400', icon: }, '0x2': { name: 'Sui Framework', color: 'text-blue-400', icon: }, '0x3': { name: 'Sui System', color: 'text-blue-400', icon: }, @@ -57,28 +74,28 @@ const KNOWN_PROTOCOLS: Record = { // DeFi - 'SwapEvent': 'Token swap executed', - 'AddLiquidityEvent': 'Liquidity added to pool', - 'RemoveLiquidityEvent': 'Liquidity removed from pool', - 'FlashLoanEvent': 'Flash loan executed', - 'BorrowEvent': 'Asset borrowed', - 'RepayEvent': 'Loan repaid', - 'LiquidateEvent': 'Position liquidated', + SwapEvent: 'Token swap executed', + AddLiquidityEvent: 'Liquidity added to pool', + RemoveLiquidityEvent: 'Liquidity removed from pool', + FlashLoanEvent: 'Flash loan executed', + BorrowEvent: 'Asset borrowed', + RepayEvent: 'Loan repaid', + LiquidateEvent: 'Position liquidated', // Oracle - 'PriceFeedUpdateEvent': 'Price feed updated', - 'TemporalNumericValueFeedUpdateEvent': 'Oracle price update', - 'PriceInfoObject': 'Price information stored', + PriceFeedUpdateEvent: 'Price feed updated', + TemporalNumericValueFeedUpdateEvent: 'Oracle price update', + PriceInfoObject: 'Price information stored', // NFT - 'MintEvent': 'NFT minted', - 'TransferEvent': 'Asset transferred', - 'BurnEvent': 'Asset burned', + MintEvent: 'NFT minted', + TransferEvent: 'Asset transferred', + BurnEvent: 'Asset burned', // Staking - 'StakeEvent': 'Tokens staked', - 'UnstakeEvent': 'Tokens unstaked', - 'ClaimRewardsEvent': 'Rewards claimed', + StakeEvent: 'Tokens staked', + UnstakeEvent: 'Tokens unstaked', + ClaimRewardsEvent: 'Rewards claimed', // General - 'PackagePublish': 'Contract deployed', - 'Upgrade': 'Contract upgraded', + PackagePublish: 'Contract deployed', + Upgrade: 'Contract upgraded', }; function truncateAddress(address: string, chars = 6): string { @@ -109,7 +126,10 @@ function getEventDescription(eventName: string): string { } // Generate from name - const words = eventName.replace(/([A-Z])/g, ' $1').trim().split(' '); + const words = eventName + .replace(/([A-Z])/g, ' $1') + .trim() + .split(' '); return words.join(' ').toLowerCase(); } @@ -118,13 +138,18 @@ function getEventIcon(eventName: string): React.ReactNode { if (name.includes('swap')) return ; if (name.includes('transfer')) return ; if (name.includes('mint')) return ; - if (name.includes('price') || name.includes('oracle') || name.includes('feed')) return ; + if (name.includes('price') || name.includes('oracle') || name.includes('feed')) + return ; if (name.includes('stake')) return ; if (name.includes('liquidity')) return ; return ; } -function EventCard({ event, isExpanded, onToggle }: { +function EventCard({ + event, + isExpanded, + onToggle, +}: { event: ParsedEvent; isExpanded: boolean; onToggle: () => void; @@ -172,9 +197,7 @@ function EventCard({ event, isExpanded, onToggle }: {
{description}
-
- {event.module} -
+
{event.module}
{/* Expanded Content */} @@ -192,9 +215,18 @@ function EventCard({ event, isExpanded, onToggle }: {
Package
- {truncateAddress(event.packageId, 8)} -
@@ -202,9 +234,18 @@ function EventCard({ event, isExpanded, onToggle }: {
Sender
- {truncateAddress(event.sender, 8)} -
@@ -214,11 +255,20 @@ function EventCard({ event, isExpanded, onToggle }: {
Event Type -
- {event.type} + + {event.type} +
{/* Event Data */} @@ -227,7 +277,9 @@ function EventCard({ event, isExpanded, onToggle }: {
Event Data
diff --git a/apps/web/src/components/FaucetForm/index.tsx b/apps/web/src/components/FaucetForm/index.tsx index 355d129..cd4ca78 100644 --- a/apps/web/src/components/FaucetForm/index.tsx +++ b/apps/web/src/components/FaucetForm/index.tsx @@ -1,9 +1,19 @@ import { clsx } from 'clsx'; -import { AlertTriangle, CheckCircle2, Copy, Droplet, ExternalLink, MessageCircle, X, XCircle } from 'lucide-react'; +import { + AlertTriangle, + CheckCircle2, + Copy, + Droplet, + ExternalLink, + MessageCircle, + X, + XCircle, +} from 'lucide-react'; import { useEffect, useState } from 'react'; import toast from 'react-hot-toast'; import { useSearchParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { useAppStore } from '@/stores/useAppStore'; import { Spinner } from '../shared/Spinner'; @@ -212,6 +222,39 @@ export function FaucetForm() { setCustomAddress(''); }; + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + }; + + const aiJson = JSON.stringify( + { + targetAddress: targetAddress ?? null, + network: selectedNetwork, + isExternalAddress, + activeEnvironment: activeEnv?.alias ?? null, + lastResult, + }, + null, + 2 + ); + + const aiMarkdown = [ + '# Sui faucet request', + '', + `- **Address:** ${targetAddress ?? '(none)'}`, + `- **Network:** ${selectedNetwork}`, + `- **External address:** ${isExternalAddress ? 'yes' : 'no'}`, + activeEnv ? `- **Active environment:** ${activeEnv.alias}` : null, + lastResult + ? `- **Last request:** ${lastResult.success ? 'success' : 'failed'} — ${lastResult.message}` + : null, + ] + .filter(Boolean) + .join('\n'); + + const aiPrompt = `Explain how to fund this Sui address on ${selectedNetwork}: ${targetAddress ?? '(no address)'}.\n\n${aiMarkdown}\n\nWalk me through requesting test tokens and list the best faucet options for this network.`; + if (!targetAddress && !activeAddress) { return
No address selected
; } @@ -231,6 +274,12 @@ export function FaucetForm() { )}
+ {isExternalAddress && (
-
@@ -274,7 +336,6 @@ export function GasAnalysis() { {breakdown && txType && gasDistribution && efficiencyInfo && ( - {/* Transaction Summary Card */}
@@ -306,13 +367,19 @@ export function GasAnalysis() {
-
~${(Number(breakdown.totalGasUsed) / 1_000_000_000 * 3.5).toFixed(4)} USD
-
{formatNumber(breakdown.totalGasUsed)} MIST
+
+ ~${((Number(breakdown.totalGasUsed) / 1_000_000_000) * 3.5).toFixed(4)} USD +
+
+ {formatNumber(breakdown.totalGasUsed)} MIST +
{/* Efficiency Badge */} -
+
{efficiencyInfo.emoji} {breakdown.efficiency}% budget used • {efficiencyInfo.label} @@ -333,7 +400,9 @@ export function GasAnalysis() { className="bg-blue-500 flex items-center justify-center" > {gasDistribution.computationPercent > 15 && ( - {gasDistribution.computationPercent}% + + {gasDistribution.computationPercent}% + )} {gasDistribution.storagePercent > 15 && ( - {gasDistribution.storagePercent}% + + {gasDistribution.storagePercent}% + )}
@@ -370,7 +441,9 @@ export function GasAnalysis() {
-
{formatSui(breakdown.computationCost)} SUI
+
+ {formatSui(breakdown.computationCost)} SUI +
@@ -383,7 +456,9 @@ export function GasAnalysis() {
-
{formatSui(breakdown.storageCost)} SUI
+
+ {formatSui(breakdown.storageCost)} SUI +
@@ -397,7 +472,9 @@ export function GasAnalysis() {
-
-{formatSui(breakdown.storageRebate)} SUI
+
+ -{formatSui(breakdown.storageRebate)} SUI +
)} @@ -411,16 +488,23 @@ export function GasAnalysis() {
Gas Budget Set - {formatSui(breakdown.totalGasBudget)} SUI + + {formatSui(breakdown.totalGasBudget)} SUI +
Actually Used - {formatSui(breakdown.totalGasUsed)} SUI + + {formatSui(breakdown.totalGasUsed)} SUI +
Unused (Returned) - {formatSui((Number(breakdown.totalGasBudget) - Number(breakdown.totalGasUsed)).toString())} SUI + {formatSui( + (Number(breakdown.totalGasBudget) - Number(breakdown.totalGasUsed)).toString() + )}{' '} + SUI
@@ -450,8 +534,11 @@ export function GasAnalysis() {
{opt.type === 'warning' ? ( @@ -462,14 +549,23 @@ export function GasAnalysis() { )}
-
+
{opt.message}
- {opt.details &&
{opt.details}
} - {opt.potentialSavings &&
{opt.potentialSavings}
} + {opt.details && ( +
{opt.details}
+ )} + {opt.potentialSavings && ( +
{opt.potentialSavings}
+ )}
))} @@ -486,11 +582,15 @@ export function GasAnalysis() {
Computation -

Processing power for executing code (loops, calculations, function calls)

+

+ Processing power for executing code (loops, calculations, function calls) +

Storage -

Cost to store data on-chain (creating objects, modifying state)

+

+ Cost to store data on-chain (creating objects, modifying state) +

Rebate @@ -498,7 +598,9 @@ export function GasAnalysis() {
Budget -

Max gas you're willing to pay. Unused gas is returned to you.

+

+ Max gas you're willing to pay. Unused gas is returned to you. +

@@ -513,13 +615,20 @@ export function GasAnalysis() {

Analyze Transaction Gas

- Understand how much gas was used, where it went, and how to optimize future transactions + Understand how much gas was used, where it went, and how to optimize future + transactions

Try these examples:
{[ - { digest: '7SZsZ8RzL7JcteKbcJh4D5xXjz6vGkuxNzj6wJtB73Dv', label: 'Oracle Update (11 events)' }, - { digest: '95iEUzhvYWZoceBtgq7LkMsZxhrtfK3iJQk7AFV6Xgnk', label: 'DeFi Transaction (29 events)' }, + { + digest: '7SZsZ8RzL7JcteKbcJh4D5xXjz6vGkuxNzj6wJtB73Dv', + label: 'Oracle Update (11 events)', + }, + { + digest: '95iEUzhvYWZoceBtgq7LkMsZxhrtfK3iJQk7AFV6Xgnk', + label: 'DeFi Transaction (29 events)', + }, ].map((example) => ( - ))} +
+ handleTabChange(v as Tab)} className="w-full"> + + {tabs.map((tab) => ( + {tab.icon}} + badge={tab.badge} + > + {tab.label} + + ))} + + +

+ {activeTab === 'keys' && 'List keys in your local keystore'} + {activeTab === 'generate' && 'Create a new keypair'} + {activeTab === 'sign' && 'Sign a message or transaction with a local key'} + {activeTab === 'multisig' && 'Build a multisig address from public keys'} + {activeTab === 'execute' && 'Execute a signed transaction'} + {activeTab === 'decode' && "Decode a signed transaction's contents"} +

{/* Tab Content */} @@ -943,8 +1038,18 @@ export function KeytoolManager() { className="p-1 hover:bg-background-active rounded transition-colors" title="Copy address" > - - + +
@@ -965,8 +1070,18 @@ export function KeytoolManager() { className="p-1 hover:bg-background-active rounded transition-colors" title="Copy public key" > - - + +
@@ -994,21 +1109,29 @@ export function KeytoolManager() { {!generatedKey || mnemonicAcknowledged ? ( <>
- + -

Ed25519 is recommended for most use cases

+

+ Ed25519 is recommended for most use cases +

- + - @@ -1160,7 +1372,10 @@ export function KeytoolManager() { className="w-full px-3 py-2.5 bg-secondary/50 border border-border/50 rounded-lg text-sm text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:border-accent/50 transition-colors font-mono" /> {keys.length > 0 && ( - )} @@ -1171,7 +1386,9 @@ export function KeytoolManager() {
Selected: - {keys[selectedSignKeyIndex].suiAddress} + + {keys[selectedSignKeyIndex].suiAddress} +
)} @@ -1180,7 +1397,9 @@ export function KeytoolManager() { {/* Sample Transaction Generator */}
- ⚡ Generate Sample Transaction + + ⚡ Generate Sample Transaction + (for testing)
@@ -1225,10 +1444,15 @@ export function KeytoolManager() { {/* Transaction Bytes */}
- +