From 682caeac0d8514bf9503759febfdec986a2e218d Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 14 Aug 2026 15:45:29 +0700 Subject: [PATCH 01/17] Add Browse Skills nav button + TextMorph loading transition for /skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nav had no direct path to the standalone /skills tree browser — only a small "Browse every file" text link buried inside the in-page catalog section. Added a proper "Browse Skills" button next to Install CLI. - Added app/skills/loading.tsx using a new TextMorph primitive, so the navigation into /skills has a visible in-between state instead of nothing happening until the route resolves. --- app/SkillPageClient.tsx | 11 ++ app/skills/loading.tsx | 42 +++++ components/motion/text-morph.tsx | 313 +++++++++++++++++++++++++++++++ 3 files changed, 366 insertions(+) create mode 100644 app/skills/loading.tsx create mode 100644 components/motion/text-morph.tsx diff --git a/app/SkillPageClient.tsx b/app/SkillPageClient.tsx index 0f16e1d..7c5c860 100644 --- a/app/SkillPageClient.tsx +++ b/app/SkillPageClient.tsx @@ -382,6 +382,17 @@ function Nav({ {repoMeta.stars !== null ? formatStarCount(repoMeta.stars) : "—"} + + Browse Skills + + navigateToSection(e, "quickstart")} diff --git a/app/skills/loading.tsx b/app/skills/loading.tsx new file mode 100644 index 0000000..3c3008f --- /dev/null +++ b/app/skills/loading.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; +import { Terminal, ArrowLeft } from "lucide-react"; +import { TextMorph } from "@/components/motion/text-morph"; + +// Next's App Router renders this automatically while the /skills route +// segment is being fetched/prepared for a client-side navigation — no +// manual state wiring needed. Same header as the real page (logo + back +// link) so the transition reads as one continuous screen, not a flash of +// something unrelated, before SkillTreeBrowser's actual content replaces +// this. +export default function SkillsLoading() { + return ( +
+
+
+ +
+ +
+ ai-devkit +
+ + + Back to catalog + +
+
+ +
+ +
+
+ ); +} diff --git a/components/motion/text-morph.tsx b/components/motion/text-morph.tsx new file mode 100644 index 0000000..aad2038 --- /dev/null +++ b/components/motion/text-morph.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; + +export interface TextMorphProps { + /** Words or phrases displayed by the morph sequence. */ + words?: string[]; + /** Time each word rests before the next morph begins, in milliseconds. */ + interval?: number; + /** Duration of the fluid morph itself, in milliseconds. */ + morphDuration?: number; + /** Additional classes applied to the component. */ + className?: string; +} + +const DEFAULT_WORDS = ["IMAGINE", "REFINE", "RELEASE"]; +const MORPH_BLUR = 12; +const MORPH_THRESHOLD = 18; + +function usePrefersReducedMotion() { + const [reduced, setReduced] = useState(false); + + useEffect(() => { + const query = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReduced(query.matches); + update(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + + return reduced; +} + +function clamp(value: number, minimum = 0, maximum = 1) { + return Math.min(maximum, Math.max(minimum, value)); +} + +function smoothstep(value: number) { + const progress = clamp(value); + return progress * progress * (3 - 2 * progress); +} + +function setLayerStyles( + element: HTMLSpanElement, + opacity: number, + blur: number, + scale: number, +) { + element.style.opacity = opacity.toFixed(4); + element.style.filter = blur > 0.01 ? `blur(${blur.toFixed(2)}px)` : "none"; + element.style.transform = `translateX(-50%) scale(${scale.toFixed(4)})`; +} + +export function TextMorph({ + words = DEFAULT_WORDS, + interval = 2600, + morphDuration = 680, + className, +}: TextMorphProps) { + const values = useMemo(() => { + const filtered = words.filter((word) => word.trim().length > 0); + return filtered.length > 0 ? filtered : DEFAULT_WORDS; + }, [words]); + const [currentIndex, setCurrentIndex] = useState(0); + const currentLayerRef = useRef(null); + const nextLayerRef = useRef(null); + const stageRef = useRef(null); + const holdTimerRef = useRef(undefined); + const frameRef = useRef(undefined); + const morphingRef = useRef(false); + const reducedMotion = usePrefersReducedMotion(); + const reactId = useId().replace(/:/g, ""); + const filterId = `text-morph-threshold-${reactId}`; + + const safeIndex = currentIndex % values.length; + const nextIndex = (safeIndex + 1) % values.length; + const currentWord = values[safeIndex]!; + const nextWord = values[nextIndex]!; + const thresholdOffset = -MORPH_THRESHOLD * 0.46; + + const measureStage = useCallback( + (target: "current" | "next", immediate = false) => { + const stage = stageRef.current; + const layer = + target === "current" ? currentLayerRef.current : nextLayerRef.current; + if (!stage || !layer) return; + + // offsetWidth/offsetHeight exclude transforms, so both layers are + // measured from their crisp resting geometry around the same center. + const width = layer.offsetWidth; + const height = layer.offsetHeight; + if (immediate || reducedMotion) { + const previousTransition = stage.style.transition; + stage.style.transition = "none"; + stage.style.width = `${width}px`; + stage.style.height = `${height}px`; + void stage.offsetWidth; + stage.style.transition = previousTransition; + return; + } + + stage.style.width = `${width}px`; + stage.style.height = `${height}px`; + }, + [reducedMotion], + ); + + useLayoutEffect(() => { + const currentLayer = currentLayerRef.current; + const nextLayer = nextLayerRef.current; + const stage = stageRef.current; + if (!currentLayer || !nextLayer || !stage) return; + + setLayerStyles(currentLayer, 1, 0, 1); + setLayerStyles(nextLayer, 0, reducedMotion ? 0 : MORPH_BLUR, 0.992); + currentLayer.style.willChange = "auto"; + nextLayer.style.willChange = "auto"; + stage.style.filter = "none"; + stage.style.transition = reducedMotion + ? "none" + : `width ${Math.max(160, morphDuration)}ms cubic-bezier(0.22, 1, 0.36, 1), height ${Math.max(160, morphDuration)}ms cubic-bezier(0.22, 1, 0.36, 1)`; + measureStage("current", true); + }, [currentIndex, measureStage, morphDuration, reducedMotion, values]); + + useEffect(() => { + const currentLayer = currentLayerRef.current; + const nextLayer = nextLayerRef.current; + if (!currentLayer || !nextLayer) return; + + const observer = new ResizeObserver(() => { + if (!morphingRef.current) measureStage("current", true); + }); + observer.observe(currentLayer); + observer.observe(nextLayer); + return () => observer.disconnect(); + }, [measureStage]); + + const beginMorph = useCallback(() => { + const currentLayer = currentLayerRef.current; + const nextLayer = nextLayerRef.current; + const stage = stageRef.current; + if ( + !currentLayer || + !nextLayer || + !stage || + morphingRef.current || + values.length < 2 + ) { + return; + } + + morphingRef.current = true; + currentLayer.style.willChange = "opacity, filter, transform"; + nextLayer.style.willChange = "opacity, filter, transform"; + stage.style.filter = reducedMotion ? "none" : `url(#${filterId})`; + measureStage("next"); + + const startedAt = performance.now(); + const resolvedDuration = reducedMotion ? 140 : Math.max(240, morphDuration); + + const renderFrame = (now: number) => { + const progress = clamp((now - startedAt) / resolvedDuration); + const eased = smoothstep(progress); + + if (reducedMotion) { + setLayerStyles(currentLayer, 1 - eased, 0, 1); + setLayerStyles(nextLayer, eased, 0, 1); + } else { + // The incoming layer starts early and the outgoing layer lingers. Their + // overlap gives the threshold filter enough shared alpha to feel fluid. + const incoming = smoothstep(clamp(progress / 0.82)); + const outgoing = smoothstep(clamp((progress - 0.18) / 0.82)); + + setLayerStyles( + currentLayer, + Math.pow(1 - outgoing, 0.55), + MORPH_BLUR * outgoing, + 1 - outgoing * 0.012, + ); + setLayerStyles( + nextLayer, + Math.pow(incoming, 0.55), + MORPH_BLUR * (1 - incoming), + 0.988 + incoming * 0.012, + ); + } + + if (progress < 1) { + frameRef.current = window.requestAnimationFrame(renderFrame); + return; + } + + stage.style.filter = "none"; + currentLayer.style.willChange = "auto"; + nextLayer.style.willChange = "auto"; + morphingRef.current = false; + setCurrentIndex(nextIndex); + }; + + frameRef.current = window.requestAnimationFrame(renderFrame); + }, [ + filterId, + measureStage, + morphDuration, + nextIndex, + reducedMotion, + values.length, + ]); + + useEffect(() => { + if (values.length < 2) return; + + holdTimerRef.current = window.setTimeout( + beginMorph, + Math.max(400, interval), + ); + + return () => { + if (holdTimerRef.current !== undefined) { + window.clearTimeout(holdTimerRef.current); + } + }; + }, [beginMorph, currentIndex, interval, values.length]); + + useEffect( + () => () => { + if (holdTimerRef.current !== undefined) { + window.clearTimeout(holdTimerRef.current); + } + if (frameRef.current !== undefined) { + window.cancelAnimationFrame(frameRef.current); + } + }, + [], + ); + + useEffect(() => { + if (currentIndex < values.length) return; + setCurrentIndex(0); + }, [currentIndex, values.length]); + + return ( + + + + + + ); +} + +export default TextMorph; From b1c918f26322a575ca2b509ed9e7b81d480ee0a1 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 19 Aug 2026 11:20:38 +0700 Subject: [PATCH 02/17] Redesign /skills as a dashboard with a code-editor-style file browser - Reskin /skills into a sidebar dashboard shell: icon rail on desktop (with plain label tooltips, no fake flyout submenus for routes that have no real children), horizontal labeled strip on mobile. Nav gains a fourth item, Prompt Inputs. - Rebuild the file content pane as a real tabbed editor: multiple open files, shiki syntax highlighting (dual light/dark theme sourced from the existing --syntax-* token palette), a scroll-position minimap, and a status bar. - Markdown files get a dedicated reading mode: collapsible outline rail, focus mode that dims other sections, and a frontmatter card that parses each skill's name/description/version instead of dumping the raw YAML block as a run-on paragraph. - Add a local-only markdown Edit mode: block/mark toolbar, a live split preview, Copy/Revert actions. Explicitly not wired to persist anywhere - these files are read live from the repo, not user drafts, so no fake "Saved" indicator. - Add Prompt Inputs: a hand-built agent composer (auto-growing textarea, model select, animated send/stop) whose "Use a skill" action inserts the real trigger phrases straight out of each skill's own SKILL.md frontmatter, not invented copy. - Add a GooeyTextReveal heading treatment on the Browse skills title. - Fix a real responsive bug: the tree pane had no flex-1/height cap below the lg breakpoint, so it grew to fit all rows unclamped and starved the file pane down to 0px height once a file was selected. Visualize interactions and Playground remain real 404s - not built yet, not faked. New deps: shiki, gsap, @gsap/react (SplitText is bundled free as of gsap 3.13+, no Club GreenSock registry needed). --- app/SkillPageClient.tsx | 9 +- app/skills/layout.tsx | 46 ++ app/skills/loading.tsx | 42 -- app/skills/not-found.tsx | 17 + app/skills/page.tsx | 84 ++- app/skills/playground/page.tsx | 7 + app/skills/prompt-inputs/page.tsx | 34 ++ app/skills/visualize-interactions/page.tsx | 7 + components/agents/prompt-input-demo.tsx | 178 ++++++ components/agents/prompt-input.tsx | 322 +++++++++++ components/motion/gooey-text-reveal.tsx | 269 +++++++++ components/motion/skills-transition-link.tsx | 32 + components/skills/file-content-pane.tsx | 394 +++++++++++++ components/skills/markdown-editor.tsx | 194 +++++++ components/skills/markdown-preview.tsx | 340 +++++++++++ components/skills/skill-tree-browser.tsx | 104 ++-- components/skills/skills-icon-rail.tsx | 73 +++ components/skills/skills-sidebar-nav.tsx | 55 ++ lib/frontmatter.ts | 20 + lib/skill-examples.ts | 38 ++ package-lock.json | 577 +++++++++++++++++++ package.json | 3 + 22 files changed, 2717 insertions(+), 128 deletions(-) create mode 100644 app/skills/layout.tsx delete mode 100644 app/skills/loading.tsx create mode 100644 app/skills/not-found.tsx create mode 100644 app/skills/playground/page.tsx create mode 100644 app/skills/prompt-inputs/page.tsx create mode 100644 app/skills/visualize-interactions/page.tsx create mode 100644 components/agents/prompt-input-demo.tsx create mode 100644 components/agents/prompt-input.tsx create mode 100644 components/motion/gooey-text-reveal.tsx create mode 100644 components/motion/skills-transition-link.tsx create mode 100644 components/skills/file-content-pane.tsx create mode 100644 components/skills/markdown-editor.tsx create mode 100644 components/skills/markdown-preview.tsx create mode 100644 components/skills/skills-icon-rail.tsx create mode 100644 components/skills/skills-sidebar-nav.tsx create mode 100644 lib/frontmatter.ts create mode 100644 lib/skill-examples.ts diff --git a/app/SkillPageClient.tsx b/app/SkillPageClient.tsx index 7c5c860..d047c64 100644 --- a/app/SkillPageClient.tsx +++ b/app/SkillPageClient.tsx @@ -10,6 +10,7 @@ import { MaskedHeading } from "@/components/motion/masked-heading"; import { DecryptReveal } from "@/components/motion/decrypt-reveal"; import BendingMarquee from "@/components/motion/bending-marquee"; import { ParticleScroll } from "@/components/motion/particle-scroll"; +import { SkillsTransitionLink } from "@/components/motion/skills-transition-link"; import { ScrambledInstallCommand, type PkgManager } from "@/components/motion/scrambled-install-command"; import { type RealSkill, CATEGORY_LABELS } from "@/lib/skill-types"; import { @@ -382,7 +383,7 @@ function Nav({ {repoMeta.stars !== null ? formatStarCount(repoMeta.stars) : "—"} - Browse Skills - +
08.0 Skill Catalog → - + Browse every file → - +
diff --git a/app/skills/layout.tsx b/app/skills/layout.tsx new file mode 100644 index 0000000..cd8d4c5 --- /dev/null +++ b/app/skills/layout.tsx @@ -0,0 +1,46 @@ +import Link from "next/link"; +import { Terminal, ArrowLeft } from "lucide-react"; +import { SkillsSidebarNav, SkillsSectionTitle } from "@/components/skills/skills-sidebar-nav"; +import { SkillsIconRail } from "@/components/skills/skills-icon-rail"; + +export default function SkillsLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + +
+
+
+ +
+ +
+ ai-devkit + +
+ ai-devkit + | + + + +
+ + + Back to catalog + +
+ +
+ +
+
{children}
+
+
+
+ ); +} diff --git a/app/skills/loading.tsx b/app/skills/loading.tsx deleted file mode 100644 index 3c3008f..0000000 --- a/app/skills/loading.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import Link from "next/link"; -import { Terminal, ArrowLeft } from "lucide-react"; -import { TextMorph } from "@/components/motion/text-morph"; - -// Next's App Router renders this automatically while the /skills route -// segment is being fetched/prepared for a client-side navigation — no -// manual state wiring needed. Same header as the real page (logo + back -// link) so the transition reads as one continuous screen, not a flash of -// something unrelated, before SkillTreeBrowser's actual content replaces -// this. -export default function SkillsLoading() { - return ( -
-
-
- -
- -
- ai-devkit -
- - - Back to catalog - -
-
- -
- -
-
- ); -} diff --git a/app/skills/not-found.tsx b/app/skills/not-found.tsx new file mode 100644 index 0000000..c1ea001 --- /dev/null +++ b/app/skills/not-found.tsx @@ -0,0 +1,17 @@ +// Segment-local so it renders inside app/skills/layout.tsx's sidebar shell +// (a not-found.tsx only bypasses layouts BELOW it in the tree) — the two +// stub routes should read as "not built yet" inside the dashboard, not +// bounce out to Next's generic default 404 page. +export default function SkillsNotFound() { + return ( +
+
+

404

+

Not built yet

+

+ This section doesn't have anything here yet — check back once it's scoped out. +

+
+
+ ); +} diff --git a/app/skills/page.tsx b/app/skills/page.tsx index eddf0d4..733a826 100644 --- a/app/skills/page.tsx +++ b/app/skills/page.tsx @@ -1,8 +1,8 @@ import type { Metadata } from "next"; -import Link from "next/link"; -import { Terminal, ArrowLeft } from "lucide-react"; -import { getSkillsTree } from "@/lib/skills-tree"; +import { Layers, FileText } from "lucide-react"; +import { getSkillsTree, type SkillTreeNode } from "@/lib/skills-tree"; import SkillTreeBrowser from "@/components/skills/skill-tree-browser"; +import { GooeyTextReveal } from "@/components/motion/gooey-text-reveal"; export const dynamic = "force-static"; @@ -11,35 +11,47 @@ export const metadata: Metadata = { description: "Every file under skills/ in CommandOSSLabs/ai-devkit, read live from the repository — SKILL.md, references, and scripts.", }; +function countStats(nodes: SkillTreeNode[]) { + let skills = 0; + let files = 0; + + function walk(list: SkillTreeNode[], depth: number) { + for (const node of list) { + if (node.type === "folder") { + if (depth === 0) skills++; + walk(node.children, depth + 1); + } else { + files++; + } + } + } + + walk(nodes, 0); + return { skills, files }; +} + +const metaChipClassName = + "flex h-9 items-center gap-1.5 rounded-lg border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-3 text-[12.5px] text-[var(--text-secondary)]"; + export default function SkillsBrowsePage() { const tree = getSkillsTree(); + const stats = countStats(tree); return ( -
-
-
- -
- -
- ai-devkit - - - - Back to catalog - -
-
- -
-
-

+
+
+ +

Browse skills

-

+

Every file under skills/ in{" "} CommandOSSLabs/ai-devkit - , read live from the repository — click a file to read it, not just its name and size. + , read live — click a file to read it.

+
+ +
+
+ + {stats.skills} skills +
+
+ + {stats.files} files +
+ + Live from repository +
+
+
-

+
); } diff --git a/app/skills/playground/page.tsx b/app/skills/playground/page.tsx new file mode 100644 index 0000000..dc6813e --- /dev/null +++ b/app/skills/playground/page.tsx @@ -0,0 +1,7 @@ +import { notFound } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +export default function PlaygroundPage() { + notFound(); +} diff --git a/app/skills/prompt-inputs/page.tsx b/app/skills/prompt-inputs/page.tsx new file mode 100644 index 0000000..39d87c8 --- /dev/null +++ b/app/skills/prompt-inputs/page.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { PromptInputDemo } from "@/components/agents/prompt-input-demo"; +import { getSkillExamples } from "@/lib/skill-examples"; + +export const dynamic = "force-static"; + +export const metadata: Metadata = { + title: "Prompt Inputs · AI DevKit Skills", + description: "An auto-growing agent composer with prompt actions, model selection, keyboard submission, and animated send and stop states.", +}; + +export default function PromptInputsPage() { + const skillExamples = getSkillExamples(); + + return ( +
+
+
+

Prompt Inputs

+

+ An auto-growing agent composer with prompt actions, model selection, keyboard submission, and animated send and stop states. +

+
+ + Interactive demo + +
+ +
+ +
+
+ ); +} diff --git a/app/skills/visualize-interactions/page.tsx b/app/skills/visualize-interactions/page.tsx new file mode 100644 index 0000000..0329de8 --- /dev/null +++ b/app/skills/visualize-interactions/page.tsx @@ -0,0 +1,7 @@ +import { notFound } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +export default function VisualizeInteractionsPage() { + notFound(); +} diff --git a/components/agents/prompt-input-demo.tsx b/components/agents/prompt-input-demo.tsx new file mode 100644 index 0000000..e6adeb2 --- /dev/null +++ b/components/agents/prompt-input-demo.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Cpu, FileText, ImagePlus, Puzzle, Rocket, Sparkles, Wind, X, Zap } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; +import type { SkillExample } from "@/lib/skill-examples"; +import { PromptInput, type PromptAction, type PromptModel } from "./prompt-input"; + +// Adapted from the component doc's usage example. That example fetches +// real provider favicons for each model icon via an external service — +// dropped here since it's a live network dependency unrelated to what this +// tab demonstrates, replaced with plain icons. +// +// The "Use a skill" action is the actual point of this tab: rather than a +// generic notice, it opens a picker built from skillExamples (read server- +// side straight off this repo's skills/*/SKILL.md frontmatter — see +// lib/skill-examples.ts) so picking a skill inserts the REAL trigger +// phrase its own description advertises, e.g. cmk-adr's "record this +// decision". That's the concrete example of "what prompt makes a model +// reach for this skill" — not invented copy. + +const MODELS: PromptModel[] = [ + { value: "claude-sonnet-5", label: "Claude Sonnet 5", icon: }, + { value: "gpt-5.2", label: "GPT-5.2", icon: }, + { value: "gemini-3.6-flash", label: "Gemini 3.6 Flash", icon: }, + { value: "grok-4.5", label: "Grok 4.5", icon: }, + { value: "mistral-large-3", label: "Mistral Large 3", icon: }, +]; + +const ACTIONS: PromptAction[] = [ + { + value: "image", + label: "Attach image", + description: "Add a screenshot or visual reference.", + icon: , + }, + { + value: "skill", + label: "Use a skill", + description: "Insert a real trigger phrase from this repo's skills.", + icon: , + }, + { + value: "context", + label: "Add context", + description: "Include a file with supporting details.", + icon: , + }, +]; + +const DEFAULT_VALUE = "Review the current implementation and suggest the next improvement."; + +export function PromptInputDemo({ skillExamples }: { skillExamples: SkillExample[] }) { + const reduce = useReducedMotion() ?? false; + const timer = useRef(undefined); + const [value, setValue] = useState(DEFAULT_VALUE); + const [loading, setLoading] = useState(false); + const [sent, setSent] = useState(); + const [notice, setNotice] = useState(); + const [pickerOpen, setPickerOpen] = useState(false); + + useEffect( + () => () => { + if (timer.current) window.clearTimeout(timer.current); + }, + [], + ); + + const submit = (prompt: string) => { + setSent(undefined); + setNotice(undefined); + setLoading(true); + timer.current = window.setTimeout(() => { + setLoading(false); + setSent(prompt); + setValue(""); + }, 900); + }; + + const stop = () => { + if (timer.current) window.clearTimeout(timer.current); + setLoading(false); + }; + + const pickExample = (skill: SkillExample, example: string) => { + setValue(example); + setPickerOpen(false); + setSent(undefined); + setNotice(`Inserted ${skill.label}'s own trigger phrase — this is what tells a model to reach for it.`); + }; + + return ( +
+ { + if (action === "skill") { + setPickerOpen((v) => !v); + return; + } + const selected = ACTIONS.find((item) => item.value === action); + setNotice(selected ? `${selected.label} selected.` : undefined); + setSent(undefined); + }} + /> + + + {pickerOpen && ( + +
+

+ Real trigger phrases from skills/*/SKILL.md +

+ +
+
+ {skillExamples.map((skill) => ( +
+

{skill.label}

+
+ {skill.examples.map((example) => ( + + ))} +
+
+ ))} +
+
+ )} +
+ +
+ + {sent || notice ? ( + + {sent ? "Prompt sent to the selected model — this is a demo, no request is actually made." : notice} + + ) : null} + +
+
+ ); +} + +export default PromptInputDemo; diff --git a/components/agents/prompt-input.tsx b/components/agents/prompt-input.tsx new file mode 100644 index 0000000..a8ccf86 --- /dev/null +++ b/components/agents/prompt-input.tsx @@ -0,0 +1,322 @@ +"use client"; + +import * as React from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { Bot, Check, ChevronDown, Plus, Send, Square } from "lucide-react"; + +// Hand-built from a component doc (props table + a consumer usage example), +// not a full source dump like most other adapted references this session — +// `npx shadcn add @beui/prompt-input` needs a components.json this repo +// doesn't have and won't get (same call made for the earlier Watermelon +// registry attempt), so this implements the documented API/behavior +// directly against this project's own design tokens. + +export type PromptModel = { value: string; label: string; icon?: React.ReactNode }; +export type PromptAction = { value: string; label: string; description?: string; icon?: React.ReactNode }; + +export interface PromptInputProps { + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + models?: PromptModel[]; + model?: string; + defaultModel?: string; + onModelChange?: (model: string) => void; + actions?: PromptAction[]; + onAction?: (action: string) => void; + onSubmit?: (value: string, model?: string) => void | Promise; + loading?: boolean; + onStop?: () => void; + minRows?: number; + maxRows?: number; + leadingAction?: React.ReactNode; + className?: string; +} + +const LINE_HEIGHT_PX = 20; + +function useOutsideClick(ref: React.RefObject, onOutside: () => void, active: boolean) { + React.useEffect(() => { + if (!active) return; + const onPointerDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onOutside(); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onOutside(); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [ref, onOutside, active]); +} + +function ActionsMenu({ actions, onAction }: { actions: PromptAction[]; onAction?: (action: string) => void }) { + const [open, setOpen] = React.useState(false); + const ref = React.useRef(null); + useOutsideClick(ref, () => setOpen(false), open); + + if (actions.length === 0) return null; + + return ( +
+ + + {open && ( + + {actions.map((a) => ( + + ))} + + )} + +
+ ); +} + +function ModelSelect({ + models, + model, + onChange, +}: { + models: PromptModel[]; + model?: string; + onChange: (value: string) => void; +}) { + const [open, setOpen] = React.useState(false); + const ref = React.useRef(null); + useOutsideClick(ref, () => setOpen(false), open); + + if (models.length === 0) return null; + const active = models.find((m) => m.value === model) ?? models[0]; + + return ( +
+ + + {open && ( + + {models.map((m) => { + const selected = m.value === active?.value; + return ( + + ); + })} + + )} + +
+ ); +} + +function SendButton({ + loading, + disabled, + onSend, + onStop, + reduceMotion, +}: { + loading: boolean; + disabled: boolean; + onSend: () => void; + onStop?: () => void; + reduceMotion: boolean | null; +}) { + return ( + + ); +} + +export function PromptInput({ + value, + defaultValue = "", + onValueChange, + models = [], + model, + defaultModel, + onModelChange, + actions = [], + onAction, + onSubmit, + loading = false, + onStop, + minRows = 2, + maxRows = 8, + leadingAction, + className = "", +}: PromptInputProps) { + const reduceMotion = useReducedMotion(); + const textareaRef = React.useRef(null); + + const [internalValue, setInternalValue] = React.useState(defaultValue); + const isValueControlled = value !== undefined; + const currentValue = isValueControlled ? value : internalValue; + + const [internalModel, setInternalModel] = React.useState(defaultModel ?? models[0]?.value); + const isModelControlled = model !== undefined; + const currentModel = isModelControlled ? model : internalModel; + + const resize = React.useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + const min = minRows * LINE_HEIGHT_PX; + const max = maxRows * LINE_HEIGHT_PX; + el.style.height = `${Math.min(Math.max(el.scrollHeight, min), max)}px`; + }, [minRows, maxRows]); + + React.useEffect(() => { + resize(); + }, [currentValue, resize]); + + const setValue = (next: string) => { + if (!isValueControlled) setInternalValue(next); + onValueChange?.(next); + }; + + const setModel = (next: string) => { + if (!isModelControlled) setInternalModel(next); + onModelChange?.(next); + }; + + const submit = () => { + const trimmed = currentValue.trim(); + if (!trimmed || loading) return; + onSubmit?.(trimmed, currentModel); + if (!isValueControlled) setInternalValue(""); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }; + + return ( +
+