diff --git a/components/CommandPalette.tsx b/components/CommandPalette.tsx
index 8bfc97b..4e6aa93 100644
--- a/components/CommandPalette.tsx
+++ b/components/CommandPalette.tsx
@@ -30,6 +30,8 @@ export function CommandPalette() {
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef(null);
+ const listRef = useRef(null);
+ const itemRefs = useRef>([]);
const previousActiveElement = useRef(null);
const close = useCallback(() => {
@@ -77,6 +79,28 @@ export function CommandPalette() {
[close, router],
);
+ // Clamp the active index when the filtered list shrinks (#651).
+ useEffect(() => {
+ setActiveIndex((i) => Math.min(i, Math.max(0, items.length - 1)));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [items.length]);
+
+ // Auto-scroll the active option into view for keyboard navigation (#651).
+ useEffect(() => {
+ itemRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" });
+ }, [activeIndex, open, query]);
+
+ // Global Escape dismissal so the palette closes even when focus is not
+ // in the search input (e.g. after mouse hover) (#651).
+ useEffect(() => {
+ if (!open) return;
+ const onEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") close();
+ };
+ document.addEventListener("keydown", onEscape);
+ return () => document.removeEventListener("keydown", onEscape);
+ }, [open, close]);
+
const onInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -136,6 +160,9 @@ export function CommandPalette() {
{
+ itemRefs.current[i] = el;
+ }}
type="button"
role="option"
aria-selected={i === activeIndex}
diff --git a/components/TokenSelector.tsx b/components/TokenSelector.tsx
index 5a47e48..3664c3a 100644
--- a/components/TokenSelector.tsx
+++ b/components/TokenSelector.tsx
@@ -1,7 +1,7 @@
'use client';
-import { useState, useEffect, useRef, useCallback } from 'react';
-import { tokenByAddress, networksForAddress, type TokenMeta } from '@/lib/tokens';
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
+import { tokenByAddress, getTokens, networksForAddress, type TokenMeta } from '@/lib/tokens';
/**
* Stellar Soroban contract addresses are base32-encoded with the RFC 4648
@@ -59,6 +59,30 @@ export function TokenSelector({
// address IS a known token — so the message can point the user there (#429).
const [otherNetworks, setOtherNetworks] = useState>([]);
+ // ── Token search filtering (#653) ─────────────────────────────────────────
+ // Debounced search across symbol, name, and 56-char contract address so
+ // custom/imported tokens are findable as the known-token list grows.
+ const [search, setSearch] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+
+ useEffect(() => {
+ const t = setTimeout(() => setDebouncedSearch(search), 200);
+ return () => clearTimeout(t);
+ }, [search]);
+
+ const filteredTokens = useMemo(() => {
+ const q = debouncedSearch.trim().toLowerCase();
+ if (!q) return [];
+ return getTokens(network).filter(t => {
+ if (t.symbol.toLowerCase().includes(q)) return true;
+ if (t.name.toLowerCase().includes(q)) return true;
+ if (t.address?.toLowerCase().includes(q)) return true;
+ return false;
+ });
+ }, [debouncedSearch, network]);
+
+ const showSearchResults = debouncedSearch.trim().length > 0;
+
// Ref to the AbortController for the current in-flight metadata lookup.
// Replaced on every new lookup so the previous one can be cancelled.
const abortRef = useRef(null);
@@ -203,6 +227,68 @@ export function TokenSelector({
{errorMsg ?? 'Could not resolve token metadata. Please try again.'}
)}
+
+ {/* Token search filter (#653) — debounced filtering across symbol,
+ name, and 56-char contract address. The result list only renders
+ while a search query is present so the default view is unchanged. */}
+
+
+ Search tokens
+
+
setSearch(e.target.value)}
+ disabled={disabled}
+ placeholder="Search by symbol, name, or address…"
+ aria-label="Search tokens"
+ autoComplete="off"
+ spellCheck={false}
+ className="input w-full mt-1"
+ />
+ {showSearchResults && (
+
+ {filteredTokens.length === 0 ? (
+
+ No tokens match “{debouncedSearch.trim()}”.
+
+ ) : (
+ filteredTokens.map(t => {
+ const isSelected = !!t.address && t.address === value;
+ return (
+
{
+ if (t.address) onChange(t.address);
+ }}
+ className="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-40"
+ >
+ {t.symbol}
+ — {t.name}
+ {t.address && (
+
+ {t.address}
+
+ )}
+
+ );
+ })
+ )}
+
+ )}
+
);
}
diff --git a/components/stream/StreamProgressBar.tsx b/components/stream/StreamProgressBar.tsx
index 65cf21f..7bf10e8 100644
--- a/components/stream/StreamProgressBar.tsx
+++ b/components/stream/StreamProgressBar.tsx
@@ -24,6 +24,14 @@
import { useRef, useEffect, useState } from 'react';
+export interface StreamMilestone { /** Unix timestamp (seconds) of the milestone (e.g. cliff unlock, cancellation window). */
+ timestamp: number;
+ /** Tooltip / accessible label for the marker. Defaults to a formatted time. */
+ label?: string;
+ /** Visual variant — cliff and cancellation markers get distinct titles. */
+ kind?: 'cliff' | 'cancellation' | 'custom';
+}
+
interface StreamProgressBarProps {
/** Unix timestamp (seconds) when the stream started */
startTime: number;
@@ -35,6 +43,10 @@ interface StreamProgressBarProps {
pausedAt?: number;
/** aria-label for screen readers */
label?: string;
+ /** Unix timestamp (seconds) when the cliff unlocks — rendered as a tick marker (#652) */
+ cliffTimestamp?: number;
+ /** Explicit milestone markers (cliffs, cancellation windows) rendered as ticks (#652) */
+ milestones?: StreamMilestone[];
}
export function StreamProgressBar({
@@ -43,6 +55,8 @@ export function StreamProgressBar({
status,
pausedAt,
label,
+ cliffTimestamp,
+ milestones = [],
}: StreamProgressBarProps) {
const fillRef = useRef(null);
@@ -131,6 +145,31 @@ export function StreamProgressBar({
const elapsedSec = totalSec > 0 ? Math.max(0, referenceSec - startTime) : 0;
const ariaNow = totalSec > 0 ? Math.min(100, Math.round((elapsedSec / totalSec) * 100)) : 0;
+ // ── Milestone markers (#652) ────────────────────────────────────────────
+ // Merge the cliffTimestamp shorthand with the explicit milestones array,
+ // then convert each in-range timestamp to a % offset along the bar.
+ // Out-of-range timestamps and open-ended streams render no markers.
+ const totalMs = endTime > 0 ? endTime * 1_000 - startTime * 1_000 : 0;
+ const allMilestones: StreamMilestone[] = [
+ ...(typeof cliffTimestamp === 'number'
+ ? [{ timestamp: cliffTimestamp, kind: 'cliff' as const }]
+ : []),
+ ...milestones,
+ ];
+ const markers = totalMs > 0
+ ? allMilestones
+ .map((m, i) => {
+ const pct = ((m.timestamp - startTime) / (endTime - startTime)) * 100;
+ if (!Number.isFinite(pct) || pct < 0 || pct > 100) return null;
+ const kindLabel = m.kind === 'cancellation' ? 'Cancellation window'
+ : m.kind === 'cliff' ? 'Cliff unlock'
+ : 'Milestone';
+ const markerLabel = m.label ?? `${kindLabel} at ${new Date(m.timestamp * 1_000).toLocaleString()}`;
+ return { ...m, pct, key: `${m.timestamp}-${i}`, markerLabel };
+ })
+ .filter((m): m is NonNullable => m !== null)
+ : [];
+
return (
<>
+ {markers.map(m => (
+
+ ))}
>
);
diff --git a/components/stream/WithdrawButton.tsx b/components/stream/WithdrawButton.tsx
index 987e8d9..b6a5f88 100644
--- a/components/stream/WithdrawButton.tsx
+++ b/components/stream/WithdrawButton.tsx
@@ -44,6 +44,26 @@ export function WithdrawButton({ streamAddress, withdrawable, token, onSuccess }
return () => { mounted.current = false; };
}, []);
+ // Rollback shake target (#695) — `triggerRollbackShake` in
+ // lib/optimistic-updates.ts animates `[data-withdrawable=""]`
+ // and emits `optimistic-withdraw-rollback` when a reverted optimistic
+ // withdrawal restores the balance.
+ const amountRef = useRef(null);
+ useEffect(() => {
+ const onRollback = (e: Event) => {
+ const addr = (e as CustomEvent<{ streamAddress?: string }>).detail?.streamAddress;
+ if (addr && addr !== streamAddress) return;
+ const el = amountRef.current;
+ if (!el) return;
+ el.classList.remove('optimistic-rollback-shake');
+ void el.offsetWidth;
+ el.classList.add('optimistic-rollback-shake');
+ window.setTimeout(() => el.classList.remove('optimistic-rollback-shake'), 450);
+ };
+ window.addEventListener('optimistic-withdraw-rollback', onRollback);
+ return () => window.removeEventListener('optimistic-withdraw-rollback', onRollback);
+ }, [streamAddress]);
+
async function handleWithdraw() {
if (!publicKey) {
setError('Connect your wallet first.');
@@ -146,7 +166,7 @@ export function WithdrawButton({ streamAddress, withdrawable, token, onSuccess }
}
return (
-
+
} data-withdrawable={streamAddress} className="space-y-2">
{!isEmpty && (
Protocol fee applies
diff --git a/lib/optimistic-updates.ts b/lib/optimistic-updates.ts
index 71c800e..6ff28f3 100644
--- a/lib/optimistic-updates.ts
+++ b/lib/optimistic-updates.ts
@@ -7,6 +7,7 @@
*/
import type { QueryClient } from '@tanstack/react-query';
+import toast from 'react-hot-toast';
import { queryKeys } from './query-keys';
import type { StreamInfo } from './stream';
@@ -92,13 +93,78 @@ export function optimisticWithdrawUpdate(
return previous;
}
+export interface RollbackWithdrawOptions {
+ /** Override the default revert explanation shown in the toast. */
+ reason?: string;
+ /** Skip the toast (e.g. caller already surfaced the error). Defaults to false. */
+ silent?: boolean;
+}
+
+const ROLLBACK_SHAKE_STYLE_ID = 'optimistic-rollback-shake-style';
+
+/**
+ * Inject the error-shake keyframes once per document (#695). The animation
+ * is applied via `triggerRollbackShake` to balance elements so a reverted
+ * optimistic withdrawal is visually explained instead of silently snapping.
+ */
+function ensureRollbackShakeStyle(): void {
+ if (typeof document === 'undefined') return;
+ if (document.getElementById(ROLLBACK_SHAKE_STYLE_ID)) return;
+ const style = document.createElement('style');
+ style.id = ROLLBACK_SHAKE_STYLE_ID;
+ style.textContent = [
+ '@keyframes optimistic-rollback-shake {',
+ ' 0%, 100% { transform: translateX(0); }',
+ ' 20% { transform: translateX(-4px); }',
+ ' 40% { transform: translateX(4px); }',
+ ' 60% { transform: translateX(-3px); }',
+ ' 80% { transform: translateX(3px); }',
+ '}',
+ '.optimistic-rollback-shake { animation: optimistic-rollback-shake 0.4s ease; }',
+ '@media (prefers-reduced-motion: reduce) {',
+ ' .optimistic-rollback-shake { animation: none; }',
+ '}',
+ ].join('\n');
+ document.head.appendChild(style);
+}
+
+/**
+ * Trigger the error-shake animation on withdrawable balance elements (#695).
+ * Falls back to a document-level CustomEvent (`optimistic-withdraw-rollback`)
+ * so any mounted balance display can animate even without the data attribute.
+ */
+export function triggerRollbackShake(streamAddress: string): void {
+ if (typeof document === 'undefined' || typeof window === 'undefined') return;
+ ensureRollbackShakeStyle();
+
+ window.dispatchEvent(
+ new CustomEvent('optimistic-withdraw-rollback', { detail: { streamAddress } }),
+ );
+
+ const selector = `[data-withdrawable="${streamAddress}"]`;
+ const targets = document.querySelectorAll(selector);
+ targets.forEach(el => {
+ el.classList.remove('optimistic-rollback-shake');
+ // Force reflow so repeated rollbacks re-trigger the animation.
+ void (el as HTMLElement).offsetWidth;
+ el.classList.add('optimistic-rollback-shake');
+ window.setTimeout(() => el.classList.remove('optimistic-rollback-shake'), 450);
+ });
+}
+
/**
* Roll back an optimistic withdraw update.
+ *
+ * When a withdrawal transaction reverts, the restored number no longer snaps
+ * silently: an error-shake animation is triggered and a toast explains the
+ * revert (#695). Pass `{ silent: true }` if the caller already surfaced the
+ * failure to avoid a duplicate toast.
*/
export function rollbackWithdraw(
qc: QueryClient,
streamAddress: string,
snapshot: bigint | undefined,
+ options?: RollbackWithdrawOptions,
): void {
const key = queryKeys.streams.withdrawable(streamAddress);
if (snapshot !== undefined) {
@@ -106,4 +172,16 @@ export function rollbackWithdraw(
} else {
qc.removeQueries({ queryKey: key });
}
+
+ triggerRollbackShake(streamAddress);
+
+ if (options?.silent !== true && typeof window !== 'undefined') {
+ const message =
+ options?.reason ?? 'Withdrawal reverted — your balance was restored.';
+ try {
+ toast.error(message);
+ } catch {
+ // No toaster mounted — the CustomEvent above still lets UI animate.
+ }
+ }
}