learning_ai_common_plat/packages/ai-ui/src/usePromptHistory.ts
saravanakumardb1 87e3bc490a feat: Wave 9.E (ai-ui@0.6.0) + Wave 13.D.1/.5 (motion@0.2.1)
──────────────────────────────────────────────────────────────────
motion@0.2.1 — Parallax + TiltGallery
──────────────────────────────────────────────────────────────────
  + Parallax.tsx
      - scroll-driven translate3d via rAF + window.scroll
      - speed multiplier + axis (y / x) + reduced-motion bypass
      - listener cleanup + cancelAnimationFrame on unmount
      - WAVE 13.D.1
  + TiltGallery.tsx
      - horizontally-scrolling rail of <TiltCard>-style tiles
      - per-tile cursor-tracking rotateX/Y + glare gradient
      - role=region + arrow-key scrolling (←/→) + scroll-snap
      - reduced-motion strips tilt + glare, keeps the rail
      - WAVE 13.D.5
  + 5 new tests (Parallax x 2, TiltGallery x 3) — 28/28 passing
  + index.ts: exports both + types
  + package.json: 0.2.0 → 0.2.1

──────────────────────────────────────────────────────────────────
ai-ui@0.6.0 — Wave 9.E composition surfaces
──────────────────────────────────────────────────────────────────
  + Markdown.tsx
      - dep-free subset renderer: h1-h3 / **bold** / *italic* /
        `code` / fenced code / ul + ol / [text](url)
      - inline `[cite:<id>]` chips resolved from a citations
        registry (missing ids render as [?] — failure mode is loud)
      - WAVE 9.E.1
  + CodeDiff.tsx
      - line-LCS diff in <100 LOC, zero deps
      - split (2-col) and unified views; tinted add/del rows
      - WAVE 9.E.2
  + ExplainThis.tsx
      - listens for selectionchange, pops 'Explain' CTA over the
        selection rect when inside the wrapper + ≥ minLength chars
      - fires onExplain({ text, rect }) so hosts can open a richer
        side panel if preferred
      - WAVE 9.E.3
  + usePromptHistory.ts
      - bash-style ↑/↓ recall with localStorage persistence
        (storage key configurable; null = in-memory for tests/SSR)
      - dedupes consecutive duplicates + trims to capacity
      - WAVE 9.E.4
  + useTokenCount.ts
      - cheap estimator (default ~4 chars/token; configurable for
        code/CJK) + optional USD cost
      - memoised — stable across re-renders
      - WAVE 9.E.5
  + 19 new tests in src/__tests__/composition.test.tsx — 98/98 passing
  + index.ts: '0.6 surfaces' section exports all 5 + types
  + package.json: 0.5.0 → 0.6.0

Showcase routes + roadmap flips land in the paired showcase commit.
2026-05-27 16:59:28 -07:00

109 lines
3.4 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
export interface UsePromptHistoryOptions {
/**
* Storage key — should be product-scoped (e.g. `notes:chat:history`).
* Pass `null` to disable persistence (in-memory only).
*/
storageKey?: string | null;
/** Maximum number of prompts retained. Default 50. */
capacity?: number;
}
export interface UsePromptHistoryHelpers {
/** The full history array, newest LAST. */
history: string[];
/** Append a prompt — dedupes consecutive duplicates + trims to capacity. */
push: (prompt: string) => void;
/** Clear all stored prompts. */
clear: () => void;
/** Cursor-up — returns the previous prompt (or null at the top). */
prev: () => string | null;
/** Cursor-down — returns the next prompt (or null at the bottom). */
next: () => string | null;
/** Reset the cursor — call when the user starts typing fresh. */
resetCursor: () => void;
}
/**
* `usePromptHistory` — bash-style ↑/↓ recall for prompt composers.
*
* Wave 9.E.4. Persistence is `localStorage` by default, scoped via
* `storageKey`. Pass `null` for in-memory only (useful for tests +
* SSR-prerender safety).
*/
export function usePromptHistory(
options: UsePromptHistoryOptions = {},
): UsePromptHistoryHelpers {
const { storageKey = 'bytelyst:prompt-history', capacity = 50 } = options;
// Lazy initial value — never touches `localStorage` during SSR.
const [history, setHistory] = useState<string[]>(() => {
if (!storageKey || typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as string[]) : [];
} catch {
return [];
}
});
// Cursor: -1 means 'not navigating'. >=0 indexes from the END (so 0
// is the newest, history.length-1 is the oldest). This matches the
// bash ↑ recall convention.
const cursorRef = useRef(-1);
useEffect(() => {
if (!storageKey || typeof window === 'undefined') return;
try {
window.localStorage.setItem(storageKey, JSON.stringify(history));
} catch {
/* quota / disabled — ignore silently */
}
}, [storageKey, history]);
const push = useCallback(
(prompt: string) => {
const trimmed = prompt.trim();
if (!trimmed) return;
setHistory((cur) => {
// De-dupe consecutive duplicate.
if (cur[cur.length - 1] === trimmed) return cur;
const next = [...cur, trimmed];
return next.length > capacity ? next.slice(next.length - capacity) : next;
});
cursorRef.current = -1;
},
[capacity],
);
const clear = useCallback(() => {
setHistory([]);
cursorRef.current = -1;
}, []);
const prev = useCallback((): string | null => {
if (history.length === 0) return null;
const newIdx = Math.min(history.length - 1, cursorRef.current + 1);
cursorRef.current = newIdx;
return history[history.length - 1 - newIdx] ?? null;
}, [history]);
const next = useCallback((): string | null => {
if (history.length === 0) return null;
if (cursorRef.current <= 0) {
cursorRef.current = -1;
return null;
}
cursorRef.current -= 1;
return history[history.length - 1 - cursorRef.current] ?? null;
}, [history]);
const resetCursor = useCallback(() => {
cursorRef.current = -1;
}, []);
return { history, push, clear, prev, next, resetCursor };
}