chore: scripts 与 preview 调试工具移出版本跟踪(.gitignore 忽略)
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "button",
|
||||
"type": "registry:ui",
|
||||
"title": "Button",
|
||||
"description": "Shared building block used by other Beautiful UI components.",
|
||||
"dependencies": [
|
||||
"class-variance-authority"
|
||||
],
|
||||
"registryDependencies": [
|
||||
"https://www.beautifului.dev/r/foundation.json"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "components/atoms/Button.tsx",
|
||||
"content": "\"use client\";\n\nimport { ButtonHTMLAttributes } from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst filledShadow = \"shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]\";\n\n/* Pill-shaped by default — the app's core button style. Explicit symmetric\n * padding (not a fixed height) so the top/bottom spacing is always equal. */\nexport const buttonVariants = cva(\n `inline-flex items-center justify-center font-medium select-none\n transition-[transform,background-color,opacity] duration-150 ease-out\n active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,\n {\n variants: {\n variant: {\n primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,\n secondary: \"bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover\",\n ghost: \"bg-hover-2 text-ink hover:bg-line-strong\",\n accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,\n success: `bg-green text-white hover:brightness-95 ${filledShadow}`,\n /* transparent until hovered — for dense toolbars/action rows */\n quiet: \"text-ink hover:bg-hover\",\n },\n size: {\n /* compact toolbar pill — fixed height, lighter weight */\n xs: \"h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1\",\n /* canonical action pill — 27px tall, roomy sides */\n sm: \"h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5\",\n md: \"px-4 py-[9px] text-sm leading-none rounded-full gap-2\",\n },\n },\n defaultVariants: { variant: \"secondary\", size: \"md\" },\n },\n);\n\nexport type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>[\"variant\"]>;\n\nexport function Button({\n variant,\n size,\n className,\n ...props\n}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {\n return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;\n}\n",
|
||||
"type": "registry:ui",
|
||||
"target": "components/atoms/Button.tsx"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "glide-menu",
|
||||
"type": "registry:ui",
|
||||
"title": "GlideMenu",
|
||||
"description": "Shared building block used by other Beautiful UI components.",
|
||||
"registryDependencies": [
|
||||
"https://www.beautifului.dev/r/foundation.json"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "components/primitives/GlideMenu.tsx",
|
||||
"content": "\"use client\";\n\nimport { useRef, useState, type ReactNode } from \"react\";\n\ntype GlideMenuProps = {\n children: ReactNode;\n className?: string;\n highlightClassName?: string;\n rowSelector?: string;\n};\n\n/** A single hover layer that glides between interactive menu rows. */\nexport default function GlideMenu({\n children,\n className = \"\",\n highlightClassName = \"inset-x-0 rounded-[8px] bg-hover\",\n rowSelector = \"[data-menu-row]\",\n}: GlideMenuProps) {\n const ref = useRef<HTMLDivElement>(null);\n const [box, setBox] = useState<{ top: number; height: number } | null>(null);\n const [visible, setVisible] = useState(false);\n\n const moveTo = (target: EventTarget | null) => {\n const container = ref.current;\n if (!(target instanceof Element) || !container) return;\n const row = target.closest(rowSelector);\n if (!(row instanceof HTMLElement) || !container.contains(row)) return;\n const containerRect = container.getBoundingClientRect();\n const rowRect = row.getBoundingClientRect();\n setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });\n setVisible(true);\n };\n\n return (\n <div\n ref={ref}\n onMouseOver={(event) => moveTo(event.target)}\n onMouseLeave={() => setVisible(false)}\n onFocusCapture={(event) => moveTo(event.target)}\n onBlurCapture={(event) => {\n if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);\n }}\n className={`group/glide-menu relative ${className}`}\n >\n <span\n aria-hidden\n className={`pointer-events-none absolute ${highlightClassName}`}\n style={{\n top: box?.top ?? 0,\n height: box?.height ?? 0,\n opacity: box && visible ? 1 : 0,\n transition:\n \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n }}\n />\n {children}\n </div>\n );\n}\n",
|
||||
"type": "registry:ui",
|
||||
"target": "components/primitives/GlideMenu.tsx"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,142 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "beautifui",
|
||||
"homepage": "https://www.beautifului.dev",
|
||||
"items": [
|
||||
{
|
||||
"name": "foundation",
|
||||
"type": "registry:style",
|
||||
"title": "Beautiful UI foundation"
|
||||
},
|
||||
{
|
||||
"name": "button",
|
||||
"type": "registry:ui",
|
||||
"title": "Button"
|
||||
},
|
||||
{
|
||||
"name": "glide-menu",
|
||||
"type": "registry:ui",
|
||||
"title": "GlideMenu"
|
||||
},
|
||||
{
|
||||
"name": "entity-chip",
|
||||
"type": "registry:ui",
|
||||
"title": "EntityChip"
|
||||
},
|
||||
{
|
||||
"name": "value-pill",
|
||||
"type": "registry:ui",
|
||||
"title": "ValuePill"
|
||||
},
|
||||
{
|
||||
"name": "shimmer",
|
||||
"type": "registry:ui",
|
||||
"title": "Shimmer"
|
||||
},
|
||||
{
|
||||
"name": "stream-text",
|
||||
"type": "registry:ui",
|
||||
"title": "StreamText"
|
||||
},
|
||||
{
|
||||
"name": "loading-state",
|
||||
"type": "registry:component",
|
||||
"title": "Loading State"
|
||||
},
|
||||
{
|
||||
"name": "thinking-state",
|
||||
"type": "registry:component",
|
||||
"title": "Thinking"
|
||||
},
|
||||
{
|
||||
"name": "streaming-text",
|
||||
"type": "registry:component",
|
||||
"title": "Streaming Text"
|
||||
},
|
||||
{
|
||||
"name": "approval-card",
|
||||
"type": "registry:component",
|
||||
"title": "Approval Card"
|
||||
},
|
||||
{
|
||||
"name": "tool-chips",
|
||||
"type": "registry:component",
|
||||
"title": "Tool Chips"
|
||||
},
|
||||
{
|
||||
"name": "task-rows",
|
||||
"type": "registry:component",
|
||||
"title": "Task Rows"
|
||||
},
|
||||
{
|
||||
"name": "chat-composer",
|
||||
"type": "registry:component",
|
||||
"title": "Chat"
|
||||
},
|
||||
{
|
||||
"name": "prompt-bar",
|
||||
"type": "registry:component",
|
||||
"title": "Prompt Bar"
|
||||
},
|
||||
{
|
||||
"name": "recommendation-card",
|
||||
"type": "registry:component",
|
||||
"title": "Recommendation Card"
|
||||
},
|
||||
{
|
||||
"name": "context-cards",
|
||||
"type": "registry:component",
|
||||
"title": "Context Cards"
|
||||
},
|
||||
{
|
||||
"name": "diff-table",
|
||||
"type": "registry:component",
|
||||
"title": "Diff Table"
|
||||
},
|
||||
{
|
||||
"name": "records-table",
|
||||
"type": "registry:component",
|
||||
"title": "Records Table"
|
||||
},
|
||||
{
|
||||
"name": "filter-table",
|
||||
"type": "registry:component",
|
||||
"title": "Filter Table"
|
||||
},
|
||||
{
|
||||
"name": "sidebar-nav",
|
||||
"type": "registry:component",
|
||||
"title": "Sidebar Nav"
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"type": "registry:component",
|
||||
"title": "Search"
|
||||
},
|
||||
{
|
||||
"name": "flowchart",
|
||||
"type": "registry:component",
|
||||
"title": "Flowchart"
|
||||
},
|
||||
{
|
||||
"name": "insight-cards",
|
||||
"type": "registry:component",
|
||||
"title": "Insight Cards"
|
||||
},
|
||||
{
|
||||
"name": "code-block",
|
||||
"type": "registry:component",
|
||||
"title": "Code Block"
|
||||
},
|
||||
{
|
||||
"name": "fine-tune-card",
|
||||
"type": "registry:component",
|
||||
"title": "Fine-tune Card"
|
||||
},
|
||||
{
|
||||
"name": "selection-actions",
|
||||
"type": "registry:component",
|
||||
"title": "Selection Actions"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "shimmer",
|
||||
"type": "registry:ui",
|
||||
"title": "Shimmer",
|
||||
"description": "Shared building block used by other Beautiful UI components.",
|
||||
"registryDependencies": [
|
||||
"https://www.beautifului.dev/r/foundation.json"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "components/atoms/Shimmer.tsx",
|
||||
"content": "/** Shimmering label — signals the agent is processing. */\nexport function Shimmer({\n children,\n className = \"\",\n}: {\n children: React.ReactNode;\n className?: string;\n}) {\n return (\n <span\n className={`inline-block bg-clip-text text-transparent ${className}`}\n style={{\n backgroundImage:\n \"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)\",\n backgroundSize: \"200% 100%\",\n animation: \"shimmer-text 1.8s linear infinite\",\n }}\n >\n {children}\n </span>\n );\n}\n",
|
||||
"type": "registry:ui",
|
||||
"target": "components/atoms/Shimmer.tsx"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,419 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { Button } from "@/components/atoms/Button";
|
||||
import GlideMenu from "@/components/primitives/GlideMenu";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* APPROVAL CARD (human-in-the-loop)
|
||||
* One question at a time. The stack slides vertically as you
|
||||
* move between questions (the card's height animates to fit),
|
||||
* the step counter rolls like an odometer, and the footer uses
|
||||
* pill actions — a quiet Skip and a dark Continue with a ⏎.
|
||||
* Single-choice answers auto-advance; multi-select waits.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
export type ApprovalQuestion = {
|
||||
q: string;
|
||||
type: "radio" | "check";
|
||||
options: string[];
|
||||
};
|
||||
|
||||
const QUESTIONS: ApprovalQuestion[] = [
|
||||
{
|
||||
q: "How many flavors should we launch?",
|
||||
type: "radio",
|
||||
options: ["Three (core line)", "Five (full case)", "Just one hero"],
|
||||
},
|
||||
{
|
||||
q: "Which mix-ins should we stock?",
|
||||
type: "check",
|
||||
options: ["Chocolate chips", "Waffle bits", "Sprinkles"],
|
||||
},
|
||||
{
|
||||
q: "Which market do we enter first?",
|
||||
type: "radio",
|
||||
options: ["Food trucks", "Grocery freezers", "Scoop shops"],
|
||||
},
|
||||
];
|
||||
|
||||
export type ApprovalLabels = {
|
||||
skip: string;
|
||||
continue: string;
|
||||
send: string;
|
||||
customPlaceholder: string;
|
||||
sentMessage: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: ApprovalLabels = {
|
||||
skip: "Skip",
|
||||
continue: "Continue",
|
||||
send: "Send",
|
||||
customPlaceholder: "Something else…",
|
||||
sentMessage: "Answers sent",
|
||||
};
|
||||
|
||||
const ROLL_MS = 400;
|
||||
const SLIDE = "360ms cubic-bezier(0.22, 1, 0.36, 1)";
|
||||
|
||||
/* odometer digits — each character that changes rolls up (or down) */
|
||||
function RollingDigits({ value }: { value: string }) {
|
||||
const prevRef = useRef(value);
|
||||
const [oldVal, setOldVal] = useState(value);
|
||||
const [newVal, setNewVal] = useState(value);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [shifted, setShifted] = useState(false);
|
||||
const [dir, setDir] = useState<"up" | "down">("up");
|
||||
|
||||
useEffect(() => {
|
||||
if (prevRef.current === value) return;
|
||||
const from = prevRef.current;
|
||||
prevRef.current = value;
|
||||
const fromN = parseInt(from, 10);
|
||||
const toN = parseInt(value, 10);
|
||||
setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? "down" : "up");
|
||||
setOldVal(from);
|
||||
setNewVal(value);
|
||||
setRolling(true);
|
||||
setShifted(false);
|
||||
|
||||
let raf2 = 0;
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => setShifted(true));
|
||||
});
|
||||
const done = setTimeout(() => {
|
||||
setRolling(false);
|
||||
setOldVal(value);
|
||||
setShifted(false);
|
||||
}, ROLL_MS);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
clearTimeout(done);
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
const chars = rolling ? newVal : oldVal;
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: chars.length }, (_, i) => {
|
||||
const o = oldVal[i] ?? "";
|
||||
const n = chars[i] ?? "";
|
||||
if (!rolling || o === n) {
|
||||
return <span key={`${i}-${n}`}>{n}</span>;
|
||||
}
|
||||
const top = dir === "down" ? n : o;
|
||||
const bottom = dir === "down" ? o : n;
|
||||
const restY = dir === "down" ? "0" : "-1em";
|
||||
const startY = dir === "down" ? "-1em" : "0";
|
||||
return (
|
||||
<span
|
||||
key={`${i}-${o}-${n}-${dir}`}
|
||||
style={{ display: "inline-block", position: "relative", overflow: "hidden", height: "1em", lineHeight: "1em", verticalAlign: "-0.05em" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transition: "transform 350ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
transform: `translateY(${shifted ? restY : startY})`,
|
||||
}}
|
||||
>
|
||||
<span style={{ height: "1em", lineHeight: "1em" }}>{top}</span>
|
||||
<span style={{ height: "1em", lineHeight: "1em" }}>{bottom}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
{path}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApprovalCard({
|
||||
questions = QUESTIONS,
|
||||
labels,
|
||||
onSubmitted,
|
||||
onAnswerChange,
|
||||
resettable = true,
|
||||
}: {
|
||||
questions?: ApprovalQuestion[];
|
||||
labels?: Partial<ApprovalLabels>;
|
||||
onSubmitted?: (answers: Record<number, number[]>) => void;
|
||||
onAnswerChange?: (questionIndex: number, answer: number[]) => void;
|
||||
resettable?: boolean;
|
||||
variant?: string;
|
||||
} = {}) {
|
||||
const t = { ...DEFAULT_LABELS, ...labels };
|
||||
const [qi, setQi] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<number, number[]>>({});
|
||||
const [custom, setCustom] = useState<Record<number, string>>({});
|
||||
const [sent, setSent] = useState(false);
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const questionRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const measured = useRef(false);
|
||||
const [viewportH, setViewportH] = useState<number | undefined>(undefined);
|
||||
const [trackY, setTrackY] = useState(0);
|
||||
const [animate, setAnimate] = useState(false);
|
||||
// Until the first question is measured, render only the active one so the
|
||||
// initial (and SSR) height is Q1's height — not all questions stacked, which
|
||||
// would flash to full height and then shrink on mount.
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const last = qi === questions.length - 1;
|
||||
const selected = answers[qi] ?? [];
|
||||
const hasAnswer = selected.length > 0 || Boolean(custom[qi]?.trim());
|
||||
|
||||
const sync = (withAnim: boolean) => {
|
||||
const item = questionRefs.current[qi];
|
||||
if (!item) return;
|
||||
const reduce = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
setViewportH(item.offsetHeight);
|
||||
setTrackY(item.offsetTop);
|
||||
setAnimate(withAnim && !reduce);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const withAnim = measured.current;
|
||||
measured.current = true;
|
||||
sync(withAnim);
|
||||
setReady(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qi, answers, custom, open, sent]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => sync(measured.current));
|
||||
return () => cancelAnimationFrame(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qi]);
|
||||
|
||||
useEffect(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); }, []);
|
||||
|
||||
const goTo = (next: number) => {
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
setQi(Math.min(Math.max(next, 0), questions.length - 1));
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
setSent(true);
|
||||
onSubmitted?.(answers);
|
||||
};
|
||||
|
||||
const advance = () => {
|
||||
if (last) send();
|
||||
else goTo(qi + 1);
|
||||
};
|
||||
|
||||
const toggle = (index: number) => {
|
||||
const type = questions[qi].type;
|
||||
setAnswers((current) => {
|
||||
const picked = current[qi] ?? [];
|
||||
const next = type === "radio"
|
||||
? [index]
|
||||
: picked.includes(index)
|
||||
? picked.filter((item) => item !== index)
|
||||
: [...picked, index];
|
||||
onAnswerChange?.(qi, next);
|
||||
return { ...current, [qi]: next };
|
||||
});
|
||||
if (type === "radio") {
|
||||
setCustom((current) => ({ ...current, [qi]: "" }));
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
advanceTimer.current = setTimeout(() => {
|
||||
if (last) send();
|
||||
else setQi((current) => Math.min(questions.length - 1, current + 1));
|
||||
}, 480);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setQi(0);
|
||||
setAnswers({});
|
||||
setCustom({});
|
||||
setSent(false);
|
||||
setOpen(true);
|
||||
measured.current = false;
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button type="button" onClick={() => setOpen(true)} className="rounded-control bg-surface px-3 py-2 text-[12.5px] font-medium text-ink shadow-btn transition-colors duration-150 hover:bg-hover">
|
||||
Open approval
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="flex w-full max-w-80 items-center gap-3" style={{ animation: "pop-in 260ms cubic-bezier(0.23,1,0.32,1) both" }}>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-tint py-1 pr-2.5 pl-1 text-[12.5px] font-medium text-green">
|
||||
<span className="flex size-4.5 items-center justify-center rounded-full bg-green text-white">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
</span>
|
||||
{t.sentMessage}
|
||||
</span>
|
||||
{resettable && (
|
||||
<button type="button" onClick={reset} className="text-[12px] font-medium text-ink-3 transition-colors duration-150 hover:text-ink">
|
||||
Start over
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-80">
|
||||
<div className="relative overflow-hidden rounded-card bg-surface shadow-card" style={{ animation: "fade-up 380ms cubic-bezier(0.23,1,0.32,1) both" }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => setOpen(false)}
|
||||
className="primitive-icon-button absolute right-2.5 top-2.5 z-10 text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<Ico size={14} sw={2.2} path={<path d="M18 6L6 18M6 6l12 12" />} />
|
||||
</button>
|
||||
<div className="primitive-card-pad">
|
||||
{/* the question itself is the heading */}
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
style={{ height: viewportH, transition: animate ? `height ${SLIDE}` : undefined }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 26,
|
||||
transform: `translate3d(0, ${-trackY}px, 0)`,
|
||||
transition: animate ? `transform ${SLIDE}` : undefined,
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
{questions.map((question, qIdx) => {
|
||||
const active = qIdx === qi;
|
||||
// Before the first measure, mount only the active question so the
|
||||
// card opens at its real height instead of flashing to full height.
|
||||
if (!ready && !active) return null;
|
||||
const picked = answers[qIdx] ?? [];
|
||||
const questionStyle: CSSProperties = {
|
||||
opacity: active ? 1 : 0,
|
||||
transition: animate ? `opacity ${SLIDE}` : undefined,
|
||||
pointerEvents: active ? undefined : "none",
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={qIdx}
|
||||
ref={(el) => { questionRefs.current[qIdx] = el; }}
|
||||
aria-hidden={active ? undefined : true}
|
||||
style={questionStyle}
|
||||
>
|
||||
<div className="pr-7 text-[14px] font-medium text-ink">{question.q}</div>
|
||||
<GlideMenu className="mt-2.5 flex flex-col gap-1" highlightClassName="inset-x-0 rounded-control bg-hover">
|
||||
{question.options.map((option, i) => {
|
||||
const on = picked.includes(i);
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
data-menu-row
|
||||
aria-pressed={on}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => { if (active) toggle(i); }}
|
||||
className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 text-left transition-colors duration-100"
|
||||
>
|
||||
<span
|
||||
className={`flex size-4 shrink-0 items-center justify-center transition-colors duration-200
|
||||
${question.type === "radio" ? "rounded-full" : "rounded-[5px]"}
|
||||
${on ? "bg-ink text-canvas" : "shadow-[inset_0_0_0_1.5px_var(--line-strong)] text-transparent"}`}
|
||||
>
|
||||
{question.type === "radio" ? (
|
||||
<span className="size-1.5 rounded-full bg-canvas transition-transform duration-200" style={{ transform: on ? "scale(1)" : "scale(0)" }} />
|
||||
) : (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-[13px] leading-none transition-colors duration-200 ${on ? "text-ink" : "text-ink-2"}`}>
|
||||
{option}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<label data-menu-row className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 transition-colors duration-100">
|
||||
<input
|
||||
value={custom[qIdx] ?? ""}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onChange={(event) => {
|
||||
if (!active) return;
|
||||
setCustom((current) => ({ ...current, [qIdx]: event.target.value }));
|
||||
if (question.type === "radio") setAnswers((current) => ({ ...current, [qIdx]: [] }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && hasAnswer) {
|
||||
event.preventDefault();
|
||||
advance();
|
||||
}
|
||||
}}
|
||||
placeholder={t.customPlaceholder}
|
||||
aria-label="Custom answer"
|
||||
className="min-w-0 flex-1 bg-transparent pl-1.5 text-[13px] text-ink outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
</label>
|
||||
</GlideMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer — step nav (rolling counter) + pill actions */}
|
||||
<div className="primitive-card-footer flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1 text-ink-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous question"
|
||||
disabled={qi <= 0}
|
||||
onClick={() => goTo(qi - 1)}
|
||||
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<Ico size={14} path={<path d="M18 15l-6-6-6 6" />} />
|
||||
</button>
|
||||
<span className="inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3" style={{ letterSpacing: "-0.1px", lineHeight: 1 }}>
|
||||
<RollingDigits value={`${qi + 1} / ${questions.length}`} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next question"
|
||||
disabled={last}
|
||||
onClick={() => goTo(qi + 1)}
|
||||
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<Ico size={14} path={<path d="M6 9l6 6 6-6" />} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="-mr-0.5 flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => (last ? setOpen(false) : goTo(qi + 1))}>
|
||||
{t.skip}
|
||||
</Button>
|
||||
<Button variant="accent" size="sm" disabled={!hasAnswer} onClick={advance}>
|
||||
{last ? t.send : t.continue}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ButtonHTMLAttributes } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]";
|
||||
|
||||
/* Pill-shaped by default — the app's core button style. Explicit symmetric
|
||||
* padding (not a fixed height) so the top/bottom spacing is always equal. */
|
||||
export const buttonVariants = cva(
|
||||
`inline-flex items-center justify-center font-medium select-none
|
||||
transition-[transform,background-color,opacity] duration-150 ease-out
|
||||
active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,
|
||||
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
|
||||
ghost: "bg-hover-2 text-ink hover:bg-line-strong",
|
||||
accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,
|
||||
success: `bg-green text-white hover:brightness-95 ${filledShadow}`,
|
||||
/* transparent until hovered — for dense toolbars/action rows */
|
||||
quiet: "text-ink hover:bg-hover",
|
||||
},
|
||||
size: {
|
||||
/* compact toolbar pill — fixed height, lighter weight */
|
||||
xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1",
|
||||
/* canonical action pill — 27px tall, roomy sides */
|
||||
sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5",
|
||||
md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "secondary", size: "md" },
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
|
||||
|
||||
export function Button({
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
|
||||
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type GlideMenuProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
highlightClassName?: string;
|
||||
rowSelector?: string;
|
||||
};
|
||||
|
||||
/** A single hover layer that glides between interactive menu rows. */
|
||||
export default function GlideMenu({
|
||||
children,
|
||||
className = "",
|
||||
highlightClassName = "inset-x-0 rounded-[8px] bg-hover",
|
||||
rowSelector = "[data-menu-row]",
|
||||
}: GlideMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [box, setBox] = useState<{ top: number; height: number } | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const moveTo = (target: EventTarget | null) => {
|
||||
const container = ref.current;
|
||||
if (!(target instanceof Element) || !container) return;
|
||||
const row = target.closest(rowSelector);
|
||||
if (!(row instanceof HTMLElement) || !container.contains(row)) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseOver={(event) => moveTo(event.target)}
|
||||
onMouseLeave={() => setVisible(false)}
|
||||
onFocusCapture={(event) => moveTo(event.target)}
|
||||
onBlurCapture={(event) => {
|
||||
if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);
|
||||
}}
|
||||
className={`group/glide-menu relative ${className}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute ${highlightClassName}`}
|
||||
style={{
|
||||
top: box?.top ?? 0,
|
||||
height: box?.height ?? 0,
|
||||
opacity: box && visible ? 1 : 0,
|
||||
transition:
|
||||
"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease",
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* LOADING STATE — pixel-grid loader for long-running work
|
||||
*
|
||||
* Variants:
|
||||
* Drive — square cells, chevron wavefront driving right;
|
||||
* the 650ms cycle is shorter than the sweep, so
|
||||
* two fronts are always in flight
|
||||
* Dots — same wavefront, circular cells
|
||||
* Orbit — a comet lapping the grid perimeter
|
||||
* Surfer — the Drive loader paired with a meme video below
|
||||
*
|
||||
* Paired with a shimmering label and a live elapsed timer
|
||||
* in mono tabular figures. Reduced motion freezes the grid
|
||||
* to its dim state; the timer still ticks.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const chevron = Array.from({ length: 9 }, (_, i) => {
|
||||
const r = Math.floor(i / 3), c = i % 3;
|
||||
return (c + Math.abs(r - 1)) * 90;
|
||||
});
|
||||
|
||||
const ORBIT_ORDER = [0, 1, 2, 5, 8, 7, 6, 3];
|
||||
const orbit = Array.from({ length: 9 }, (_, i) => {
|
||||
const k = ORBIT_ORDER.indexOf(i);
|
||||
return k === -1 ? null : k * 110;
|
||||
});
|
||||
|
||||
const PATTERNS: Record<string, { delays: (number | null)[]; dur: number; round: boolean }> = {
|
||||
Drive: { delays: chevron, dur: 650, round: false },
|
||||
Dots: { delays: chevron, dur: 650, round: true },
|
||||
Orbit: { delays: orbit, dur: 950, round: false },
|
||||
};
|
||||
|
||||
function LoaderGrid({
|
||||
delays,
|
||||
dur,
|
||||
round,
|
||||
}: {
|
||||
delays: (number | null)[];
|
||||
dur: number;
|
||||
round: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span aria-hidden className="grid shrink-0 grid-cols-[repeat(3,4px)] gap-[1.5px]">
|
||||
{delays.map((delay, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`size-[4px] bg-ink ${round ? "rounded-full" : "rounded-[1px]"}`}
|
||||
style={{
|
||||
opacity: delay === null ? 0.07 : 0.15,
|
||||
animation: delay === null ? "none" : `pixel-on ${dur}ms ease-in-out ${delay}ms infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function useElapsed() {
|
||||
const [ds, setDs] = useState(0);
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setDs((d) => d + 1), 100);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
const total = ds / 10;
|
||||
if (total < 60) return `${total.toFixed(1)}s`;
|
||||
return `${Math.floor(total / 60)}m ${(total % 60).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export default function LoadingState({
|
||||
label,
|
||||
variant = "Drive",
|
||||
/** the meme feed for the Surfer variant; hosted on Vercel Blob so it plays in
|
||||
* production (the local /public/subway-surfers.mp4 stays gitignored) */
|
||||
videoSrc = "https://95dnc2a95qgwt9ff.public.blob.vercel-storage.com/subway-surfers.mp4",
|
||||
}: {
|
||||
label?: string;
|
||||
variant?: string;
|
||||
videoSrc?: string;
|
||||
}) {
|
||||
const elapsed = useElapsed();
|
||||
const surfer = variant === "Surfer";
|
||||
const resolvedLabel = label ?? (surfer ? "Subway surfing" : "Churning");
|
||||
const [videoOk, setVideoOk] = useState(true);
|
||||
const { delays, dur, round } = PATTERNS[variant] ?? PATTERNS.Drive;
|
||||
|
||||
const labelEl = (
|
||||
<span
|
||||
className="bg-clip-text text-[13px] font-medium text-transparent"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer-text 1.4s linear infinite",
|
||||
}}
|
||||
>
|
||||
{resolvedLabel}
|
||||
</span>
|
||||
);
|
||||
const elapsedEl = <span className="font-mono text-[12px] text-ink-3 tabular-nums">{elapsed}</span>;
|
||||
|
||||
if (surfer) {
|
||||
return (
|
||||
<div role="status" className="flex w-fit flex-col items-start">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LoaderGrid {...PATTERNS.Drive} />
|
||||
{labelEl}
|
||||
{elapsedEl}
|
||||
</div>
|
||||
|
||||
{/* the context card follows the status text it is illustrating */}
|
||||
<div
|
||||
className="mt-2 w-56 overflow-hidden rounded-[10px] shadow-overlay"
|
||||
style={{ animation: "pop-in 200ms cubic-bezier(0.16,1,0.3,1) both", transformOrigin: "top left" }}
|
||||
>
|
||||
<div className="relative aspect-video w-full" style={{ background: "var(--tooltip-bg)" }}>
|
||||
{videoOk ? (
|
||||
<video
|
||||
src={videoSrc}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
onError={() => setVideoOk(false)}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5">
|
||||
<LoaderGrid {...PATTERNS.Drive} />
|
||||
<span className="px-3 text-center font-mono text-[10px]" style={{ color: "var(--tooltip-muted)" }}>
|
||||
Video unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="status" className="flex w-fit items-center gap-2.5">
|
||||
<LoaderGrid delays={delays} dur={dur} round={round} />
|
||||
{labelEl}
|
||||
{elapsedEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { IconArrowBoxLeft } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconArrowBoxLeft";
|
||||
import { IconCheckmark1Small } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconCheckmark1Small";
|
||||
import { IconChevronDownSmall } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconChevronDownSmall";
|
||||
import { IconCrossSmall } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconCrossSmall";
|
||||
import { IconEditBig } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconEditBig";
|
||||
import { IconHome } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconHome";
|
||||
import { IconMagnifyingGlass } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconMagnifyingGlass";
|
||||
import { IconPlusMedium } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPlusMedium";
|
||||
import { IconPopsicle2 } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPopsicle2";
|
||||
import { IconSettingsGear1 } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSettingsGear1";
|
||||
import { IconSidebarLeftArrow } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSidebarLeftArrow";
|
||||
import { IconUserAdd } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconUserAdd";
|
||||
import GlideMenu from "@/components/primitives/GlideMenu";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* SIDEBAR NAV
|
||||
* Shared by the design-system preview and the harness shell:
|
||||
* compact workspace switcher, primary navigation, searchable
|
||||
* chat history, and a collapse that preserves icon alignment.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const WORKSPACE = { key: "creamery", name: "Creamery Ops", monogram: "C" };
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: "home", label: "Home", icon: <IconHome size={18} /> },
|
||||
{ key: "invite", label: "Invite users", icon: <IconUserAdd size={18} />, count: "3/10" },
|
||||
];
|
||||
|
||||
export type SidebarRecent = {
|
||||
id: string;
|
||||
label: string;
|
||||
prompt?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_RECENTS: SidebarRecent[] = [
|
||||
{ id: "suppliers", label: "Supplier records" },
|
||||
{ id: "todos", label: "Urgent to-dos this morning" },
|
||||
{ id: "flavor", label: "Flavor page ticket" },
|
||||
{ id: "workload", label: "Workload summary" },
|
||||
{ id: "offboarding", label: "Off-board a supplier" },
|
||||
{ id: "restock", label: "Batch restock function" },
|
||||
{ id: "edits", label: "Propose flavor edits" },
|
||||
{ id: "subway", label: "Subway surfing" },
|
||||
];
|
||||
|
||||
type SidebarNavProps = {
|
||||
activeTitle?: string | null;
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
onNewChat?: () => void;
|
||||
onPick?: (id: string, label: string, prompt?: string) => void;
|
||||
/** controlled primary-nav selection (e.g. "home" | "invite") */
|
||||
activeNav?: string;
|
||||
onNavigate?: (key: string) => void;
|
||||
/** footer call-to-action — defaults to the demo "Upgrade" button */
|
||||
footerLabel?: string;
|
||||
footerIcon?: ReactNode;
|
||||
onFooterClick?: () => void;
|
||||
recents?: SidebarRecent[];
|
||||
variant?: string;
|
||||
};
|
||||
|
||||
const SIDEBAR_MOTION = {
|
||||
expandedWidth: 224,
|
||||
collapsedWidth: 52,
|
||||
duration: 280,
|
||||
copyDuration: 180,
|
||||
copyOffset: 8,
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* CHAT SEARCH STORYBOARD
|
||||
*
|
||||
* 0ms search is triggered; Chats label begins fading
|
||||
* 0ms field grows right → left from the search control
|
||||
* 180ms field fills the row; cursor is focused and ready
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
const CHAT_SEARCH_MOTION = {
|
||||
duration: 180,
|
||||
closedWidth: 28,
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
};
|
||||
|
||||
function GlideGroup({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<GlideMenu
|
||||
rowSelector="[data-row]"
|
||||
highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2"
|
||||
className="group/glide flex flex-col gap-px"
|
||||
>
|
||||
{children}
|
||||
</GlideMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function RailButton({
|
||||
icon,
|
||||
label,
|
||||
active = false,
|
||||
count,
|
||||
onClick,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
count?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
data-row
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`sidebar-row relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left
|
||||
transition-[width,background-color,color,transform] duration-150 active:scale-[0.98]
|
||||
${active ? "bg-hover-2 group-hover/glide:bg-transparent" : ""}`}
|
||||
>
|
||||
<span className={`flex size-5 shrink-0 items-center justify-center ${active ? "text-ink" : "text-ink-2"}`}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className={`sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium ${active ? "text-ink" : "text-ink-2"}`}>
|
||||
{label}
|
||||
</span>
|
||||
{count && (
|
||||
<span className="sidebar-copy mr-2 shrink-0 text-[12px] font-medium tabular-nums text-ink-3">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceMenu({
|
||||
position,
|
||||
onClose,
|
||||
}: {
|
||||
position: { top: number; left: number };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return createPortal(
|
||||
<div
|
||||
data-workspace-menu
|
||||
className="fixed z-50 w-64 rounded-[14px] bg-surface p-1.5 shadow-overlay"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
animation: "pop-in 180ms cubic-bezier(0.23,1,0.32,1) both",
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
<GlideMenu className="flex flex-col gap-px" highlightClassName="inset-x-0 rounded-[8px] bg-hover-2">
|
||||
<button
|
||||
data-menu-row
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="relative z-10 flex h-10 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-[7px] bg-ink text-[11px] font-semibold text-surface">
|
||||
{WORKSPACE.monogram}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] font-medium text-ink">{WORKSPACE.name}</span>
|
||||
<span className="shrink-0 text-ink"><IconCheckmark1Small size={18} /></span>
|
||||
</button>
|
||||
<div className="my-1 h-px bg-line" />
|
||||
{[
|
||||
{ label: "New workspace", icon: <IconPlusMedium size={16} /> },
|
||||
{ label: "Workspace settings", icon: <IconSettingsGear1 size={16} /> },
|
||||
{ label: "Invite team members", icon: <IconUserAdd size={16} /> },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
data-menu-row
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="relative z-10 flex h-9 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2">{item.icon}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-ink">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="my-1 h-px bg-line" />
|
||||
<button
|
||||
data-menu-row
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="relative z-10 flex h-9 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2"><IconArrowBoxLeft size={16} /></span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-ink">Sign out</span>
|
||||
</button>
|
||||
</GlideMenu>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default function SidebarNav({
|
||||
activeTitle,
|
||||
className = "",
|
||||
fill = false,
|
||||
onNewChat,
|
||||
onPick,
|
||||
activeNav,
|
||||
onNavigate,
|
||||
footerLabel = "Upgrade",
|
||||
footerIcon,
|
||||
onFooterClick,
|
||||
recents = DEFAULT_RECENTS,
|
||||
}: SidebarNavProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [internalNav, setInternalNav] = useState("chats");
|
||||
const currentNav = activeNav ?? internalNav;
|
||||
const selectNav = (key: string) => {
|
||||
setInternalNav(key);
|
||||
onNavigate?.(key);
|
||||
};
|
||||
const [demoActiveTitle, setDemoActiveTitle] = useState<string | null>(null);
|
||||
const [workspaceOpen, setWorkspaceOpen] = useState(false);
|
||||
const [workspacePosition, setWorkspacePosition] = useState({ top: 0, left: 0 });
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const workspaceButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectedTitle = activeTitle === undefined ? demoActiveTitle : activeTitle;
|
||||
const visibleRecents = recents.filter((item) => item.label.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceOpen) return;
|
||||
const close = (event: PointerEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest("[data-workspace-trigger]") && !target.closest("[data-workspace-menu]")) {
|
||||
setWorkspaceOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", close);
|
||||
return () => document.removeEventListener("pointerdown", close);
|
||||
}, [workspaceOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchOpen) searchRef.current?.focus();
|
||||
}, [searchOpen]);
|
||||
|
||||
const collapse = () => {
|
||||
setCollapsed(true);
|
||||
setWorkspaceOpen(false);
|
||||
setSearchOpen(false);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-sidebar-collapsed={collapsed}
|
||||
aria-label="Workspace navigation"
|
||||
className={`relative flex shrink-0 overflow-hidden transition-[width] ${fill ? "h-full" : "h-[600px]"} ${className}`}
|
||||
style={{
|
||||
width: collapsed ? SIDEBAR_MOTION.collapsedWidth : SIDEBAR_MOTION.expandedWidth,
|
||||
transitionDuration: `${SIDEBAR_MOTION.duration}ms`,
|
||||
transitionTimingFunction: SIDEBAR_MOTION.easing,
|
||||
"--sidebar-copy-duration": `${SIDEBAR_MOTION.copyDuration}ms`,
|
||||
"--sidebar-copy-offset": `${SIDEBAR_MOTION.copyOffset}px`,
|
||||
"--sidebar-easing": SIDEBAR_MOTION.easing,
|
||||
} as CSSProperties}
|
||||
>
|
||||
<div className="flex min-h-0 w-[224px] shrink-0 flex-col">
|
||||
<div className="relative mb-2.5 h-10 shrink-0">
|
||||
<button
|
||||
ref={workspaceButtonRef}
|
||||
data-workspace-trigger
|
||||
type="button"
|
||||
aria-expanded={workspaceOpen}
|
||||
aria-hidden={collapsed}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={() => {
|
||||
if (!workspaceOpen && workspaceButtonRef.current) {
|
||||
const rect = workspaceButtonRef.current.getBoundingClientRect();
|
||||
setWorkspacePosition({ top: rect.bottom + 6, left: rect.left });
|
||||
}
|
||||
setWorkspaceOpen((open) => !open);
|
||||
}}
|
||||
className="sidebar-workspace-control absolute left-2 top-1 flex h-8 w-[164px] items-center rounded-[8px] px-2 text-left transition-[background-color,transform] duration-100 hover:bg-hover-2 active:scale-[0.99]"
|
||||
>
|
||||
<span className="sidebar-logo flex size-5 shrink-0 items-center justify-center text-ink">
|
||||
<IconPopsicle2 size={18} />
|
||||
</span>
|
||||
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium text-ink-2">
|
||||
{WORKSPACE.name}
|
||||
</span>
|
||||
<span className="sidebar-copy ml-1 flex shrink-0 text-ink-3">
|
||||
<IconChevronDownSmall size={16} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{workspaceOpen && <WorkspaceMenu position={workspacePosition} onClose={() => setWorkspaceOpen(false)} />}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Collapse sidebar"
|
||||
aria-hidden={collapsed}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={collapse}
|
||||
className="sidebar-collapse-control absolute right-2 top-1 flex size-8 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color] duration-150 hover:bg-hover-2 hover:text-ink"
|
||||
>
|
||||
<IconSidebarLeftArrow size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand sidebar"
|
||||
aria-hidden={!collapsed}
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="sidebar-expand-control absolute left-2 top-0.5 flex size-9 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color] duration-150 hover:bg-hover-2 hover:text-ink"
|
||||
>
|
||||
<IconSidebarLeftArrow size={18} className="rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<GlideGroup>
|
||||
<RailButton
|
||||
icon={<IconEditBig size={18} />}
|
||||
label="New chat"
|
||||
onClick={() => {
|
||||
if (activeTitle === undefined) setDemoActiveTitle(null);
|
||||
selectNav("chats");
|
||||
onNewChat?.();
|
||||
}}
|
||||
/>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<RailButton
|
||||
key={item.key}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
count={item.count}
|
||||
active={currentNav === item.key}
|
||||
onClick={() => selectNav(item.key)}
|
||||
/>
|
||||
))}
|
||||
</GlideGroup>
|
||||
|
||||
<div className="mt-3 min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="sidebar-copy relative mx-2 mb-1 h-8">
|
||||
<div
|
||||
aria-hidden={searchOpen}
|
||||
className={`absolute inset-0 flex items-center gap-1.5 px-2 text-[12.5px] font-medium text-ink-3 transition-[opacity,transform] ${searchOpen ? "pointer-events-none -translate-x-1 opacity-0" : "translate-x-0 opacity-100"}`}
|
||||
style={{ transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms`, transitionTimingFunction: CHAT_SEARCH_MOTION.easing }}
|
||||
>
|
||||
<IconChevronDownSmall size={16} />
|
||||
<span>Chats</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Search chats"
|
||||
aria-expanded={searchOpen}
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className={`absolute right-0 top-0 z-10 flex size-8 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color,transform] hover:bg-hover-2 hover:text-ink active:scale-[0.96] ${searchOpen ? "pointer-events-none opacity-0" : "opacity-100"}`}
|
||||
style={{ transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms` }}
|
||||
>
|
||||
<IconMagnifyingGlass size={16} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`absolute right-0 top-0 z-20 flex h-8 items-center overflow-hidden rounded-[8px] bg-field text-ink-3 shadow-hairline transition-[width,opacity] focus-within:text-ink-2 ${searchOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
style={{
|
||||
width: searchOpen ? "100%" : CHAT_SEARCH_MOTION.closedWidth,
|
||||
transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms`,
|
||||
transitionTimingFunction: CHAT_SEARCH_MOTION.easing,
|
||||
}}
|
||||
>
|
||||
<span className="ml-2 flex shrink-0 items-center justify-center">
|
||||
<IconMagnifyingGlass size={15} />
|
||||
</span>
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setSearchOpen(false);
|
||||
setQuery("");
|
||||
}
|
||||
}}
|
||||
placeholder="Search chats"
|
||||
aria-label="Search chat history"
|
||||
className="ml-1.5 min-w-0 flex-1 bg-transparent text-[13px] font-medium text-ink outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close chat search"
|
||||
onClick={() => {
|
||||
setSearchOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink active:scale-[0.96]"
|
||||
>
|
||||
<IconCrossSmall size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlideGroup>
|
||||
{visibleRecents.map((item) => {
|
||||
const active = item.label === selectedTitle;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
data-row
|
||||
type="button"
|
||||
title={item.label}
|
||||
onClick={() => {
|
||||
selectNav("chats");
|
||||
if (activeTitle === undefined) setDemoActiveTitle(item.label);
|
||||
onPick?.(item.id, item.label, item.prompt);
|
||||
}}
|
||||
className={`sidebar-row relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left transition-[width,background-color,color,transform] duration-150 active:scale-[0.98] ${
|
||||
active ? "bg-hover-2 group-hover/glide:bg-transparent" : ""
|
||||
}`}
|
||||
>
|
||||
<span className={`sidebar-copy min-w-0 flex-1 truncate text-[14px] font-medium ${active ? "text-ink" : "text-ink-2"}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{query && visibleRecents.length === 0 && (
|
||||
<div className="sidebar-copy mx-2 px-2 py-2 text-[12.5px] text-ink-3">No chats found</div>
|
||||
)}
|
||||
</GlideGroup>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-copy mx-2 mt-3 w-[208px] border-t border-line pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFooterClick ?? onNewChat}
|
||||
className="flex h-8 w-full items-center justify-center gap-1.5 rounded-control bg-hover-2 text-[12.5px] font-medium text-ink transition-[background-color,transform] duration-150 hover:bg-line-strong active:scale-[0.98]"
|
||||
>
|
||||
{footerIcon}
|
||||
{footerLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* STREAMING TEXT
|
||||
* Words resolve out of blur, inline citations appear in
|
||||
* context, then actions and follow-up prompts become usable.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const WORD_MS = 55;
|
||||
const HOLD_MS = 3400;
|
||||
|
||||
/* one streamed word, or a `cite` placeholder that renders an inline source chip */
|
||||
export type StreamingToken = { text: string; cite?: boolean };
|
||||
|
||||
const TOKENS: StreamingToken[] = [
|
||||
..."Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points."
|
||||
.split(" ")
|
||||
.map((text) => ({ text })),
|
||||
{ text: "", cite: true },
|
||||
..."Stone-fruit flavors are trending in the same range."
|
||||
.split(" ")
|
||||
.map((text) => ({ text })),
|
||||
];
|
||||
|
||||
const FOLLOW_UPS = [
|
||||
"Which flavors sell best in winter",
|
||||
"Compare gelato and soft serve margins",
|
||||
];
|
||||
|
||||
const SOURCE_IMAGES = {
|
||||
scoop:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
|
||||
trends:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E",
|
||||
market:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
|
||||
};
|
||||
|
||||
/* one cited source rendered as an inline chip and in the sources list */
|
||||
export type StreamingSource = { name: string; domain: string; href: string; image: string };
|
||||
|
||||
const SOURCES: StreamingSource[] = [
|
||||
{ name: "Scoop Data", domain: "scoopdata.io", href: "https://scoopdata.io/", image: SOURCE_IMAGES.scoop },
|
||||
{ name: "Trends Index", domain: "trends.google.com", href: "https://trends.google.com/trends/", image: SOURCE_IMAGES.trends },
|
||||
{ name: "Market Basket", domain: "marketbasket.io", href: "https://marketbasket.io/", image: SOURCE_IMAGES.market },
|
||||
];
|
||||
|
||||
function sourceImage(source: StreamingSource) {
|
||||
return source.image;
|
||||
}
|
||||
|
||||
function SourceChip({ source }: { source?: StreamingSource }) {
|
||||
if (!source) return null;
|
||||
return (
|
||||
<a
|
||||
href={source.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-0 mr-1 inline-flex h-4.5 translate-y-[-1px] items-center gap-1 rounded-[5px]
|
||||
bg-inset pr-[3px] pl-[3px] align-middle font-mono text-[10.5px] text-ink-2 shadow-hairline
|
||||
transition-colors duration-150 hover:bg-hover hover:text-ink"
|
||||
style={{ animation: "pop-in 250ms cubic-bezier(0.23,1,0.32,1) both" }}
|
||||
>
|
||||
<img src={sourceImage(source)} alt="" className="source-avatar size-3 rounded-[3px]" />
|
||||
<span>{source.domain}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_ICONS: React.ReactNode[] = [
|
||||
<g key="copy"><rect x="9" y="9" width="12" height="12" rx="2.5" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></g>,
|
||||
<path key="retry" d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" />,
|
||||
<path key="up" d="M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z" />,
|
||||
<path key="down" d="M17 14V2M9 18.12L10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88z" />,
|
||||
];
|
||||
|
||||
export type StreamingLabels = {
|
||||
/** label on the collapsed sources toggle */
|
||||
sources: string;
|
||||
/** heading above the follow-up prompts */
|
||||
followUps: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: StreamingLabels = {
|
||||
sources: "10 sources",
|
||||
followUps: "Follow-ups",
|
||||
};
|
||||
|
||||
export default function StreamingText({
|
||||
content = TOKENS,
|
||||
sources = SOURCES,
|
||||
followUps = FOLLOW_UPS,
|
||||
labels,
|
||||
loop = true,
|
||||
fill = false,
|
||||
onDone,
|
||||
onFollowUp,
|
||||
}: {
|
||||
variant?: string;
|
||||
/** the streamed tokens; `cite` tokens render an inline source chip */
|
||||
content?: StreamingToken[];
|
||||
/** cited sources shown in the chip, avatar stack, and expanded list */
|
||||
sources?: StreamingSource[];
|
||||
/** follow-up prompt suggestions shown once the stream completes */
|
||||
followUps?: string[];
|
||||
/** prominent copy strings */
|
||||
labels?: Partial<StreamingLabels>;
|
||||
/** restart the stream after a hold; turn off when embedding in a real thread */
|
||||
loop?: boolean;
|
||||
/** fill the parent width instead of the gallery's fixed measure */
|
||||
fill?: boolean;
|
||||
onDone?: () => void;
|
||||
/** fired when a follow-up prompt is chosen */
|
||||
onFollowUp?: (text: string, index: number) => void;
|
||||
} = {}) {
|
||||
const l = { ...DEFAULT_LABELS, ...labels };
|
||||
const [count, setCount] = useState(0);
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const done = count >= content.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (done && !loop) {
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(
|
||||
() => setCount((c) => (c >= content.length ? 0 : c + 1)),
|
||||
done ? HOLD_MS : WORD_MS,
|
||||
);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [count, done, loop]);
|
||||
|
||||
return (
|
||||
<div className={fill ? "w-full" : "min-h-[15.5rem] w-full max-w-95"}>
|
||||
<p className="text-[13px] leading-relaxed text-ink">
|
||||
{content.slice(0, count).map((token, i) =>
|
||||
token.cite ? (
|
||||
<SourceChip key={i} source={sources[0]} />
|
||||
) : (
|
||||
<span key={i} className="inline">
|
||||
{token.text}{" "}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
{!done && (
|
||||
<span
|
||||
className="ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink"
|
||||
style={{ animation: "fade-in 150ms ease-out both" }}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* action icons row */}
|
||||
<div
|
||||
className="mt-2 flex items-center gap-0.5 transition-opacity duration-400"
|
||||
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
|
||||
>
|
||||
{ACTION_ICONS.map((icon, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
aria-label="Action"
|
||||
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3
|
||||
transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{icon}
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={sourcesOpen}
|
||||
onClick={() => setSourcesOpen((current) => !current)}
|
||||
className="ml-1.5 flex items-center gap-1.5 rounded-[6px] px-1 py-0.5 text-left transition-colors duration-150 hover:bg-hover"
|
||||
>
|
||||
<span className="flex -space-x-1">
|
||||
{sources.map((source) => (
|
||||
<img
|
||||
key={source.domain}
|
||||
src={sourceImage(source)}
|
||||
alt=""
|
||||
className="source-avatar size-3.5 rounded-full bg-surface shadow-[0_0_0_1.5px_var(--canvas)]"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-[12px] text-ink-2">{l.sources}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-300"
|
||||
style={{
|
||||
gridTemplateRows: done && sourcesOpen ? "1fr" : "0fr",
|
||||
opacity: done && sourcesOpen ? 1 : 0,
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-1.5 flex flex-col rounded-[10px] bg-inset p-1 shadow-hairline">
|
||||
{sources.map((source) => (
|
||||
<a
|
||||
key={source.domain}
|
||||
href={source.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-[6px] px-1.5 py-1 text-[12px] text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<img src={sourceImage(source)} alt="" className="source-avatar size-4 rounded-[4px]" />
|
||||
<span className="animated-underline">{source.name}</span>
|
||||
<span className="ml-auto font-mono text-[10.5px] text-ink-3">{source.domain}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* follow-ups */}
|
||||
<div
|
||||
className="mt-2.5 transition-opacity duration-400"
|
||||
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
|
||||
>
|
||||
<p className="text-[12px] font-medium text-ink-2">{l.followUps}</p>
|
||||
<div className="mt-0.5 flex flex-col">
|
||||
{followUps.map((text, i) => (
|
||||
<button
|
||||
key={text}
|
||||
onClick={() => onFollowUp?.(text, i)}
|
||||
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line
|
||||
px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors
|
||||
duration-100 hover:bg-hover-2"
|
||||
style={
|
||||
done
|
||||
? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }
|
||||
: { opacity: 0 }
|
||||
}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M9 10l-5 5 5 5" />
|
||||
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
|
||||
</svg>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* THINKING — expandable agent trace, four variants
|
||||
*
|
||||
* Steps step list with spinner → muted checks
|
||||
* Reasoning prose reasoning that expands, then settles
|
||||
* Search web-search trace: query + sources read
|
||||
* Coding tool trace: files read, edits, commands
|
||||
*
|
||||
* The trace runs once, settles, and remains expandable.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const STAGES = [800, 600, 1800, 2600, 1600];
|
||||
|
||||
function useSequence(steps: number[]) {
|
||||
const [stage, setStage] = useState(0);
|
||||
useEffect(() => {
|
||||
if (stage >= steps.length - 1) return;
|
||||
const t = setTimeout(() => setStage((s) => s + 1), steps[stage]);
|
||||
return () => clearTimeout(t);
|
||||
}, [stage, steps]);
|
||||
return stage;
|
||||
}
|
||||
|
||||
type Row = {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
mono?: boolean;
|
||||
add?: number;
|
||||
del?: number;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
const VARIANTS: Record<
|
||||
string,
|
||||
{ active: string; done: string; rows: Row[]; query?: string }
|
||||
> = {
|
||||
Steps: {
|
||||
active: "Thinking",
|
||||
done: "Thought for 4 seconds",
|
||||
rows: [
|
||||
{ primary: "Reading flavor briefs" },
|
||||
{ primary: "Scanning supplier lists" },
|
||||
{ primary: "Comparing tasting notes", secondary: "6 flavors" },
|
||||
{ primary: "Writing the scoop report" },
|
||||
],
|
||||
},
|
||||
Reasoning: {
|
||||
active: "Thinking",
|
||||
done: "Thought for 4 seconds",
|
||||
rows: [
|
||||
{ primary: "Summer demand spikes for stone-fruit flavors — peach and apricot lead." },
|
||||
{ primary: "I should check cone inventory before promoting a waffle-bowl special." },
|
||||
],
|
||||
},
|
||||
Search: {
|
||||
active: "Searching the web",
|
||||
done: "Searched the web",
|
||||
query: "best waffle cone supplier",
|
||||
rows: [
|
||||
{ primary: "Joy Cone", secondary: "joycone.com", href: "https://joycone.com/fs_products/waffle-cones/" },
|
||||
{ primary: "WebstaurantStore", secondary: "webstaurantstore.com", href: "https://www.webstaurantstore.com/ice-cream-shop-supplies.html" },
|
||||
{ primary: "The Konery", secondary: "thekonery.com", href: "https://www.thekonery.com/" },
|
||||
],
|
||||
},
|
||||
Coding: {
|
||||
active: "Running tools",
|
||||
done: "Ran 3 tools",
|
||||
rows: [
|
||||
{ primary: "Read", secondary: "flavors.ts", mono: true },
|
||||
{ primary: "Edit", secondary: "ChurnSchedule.tsx", mono: true, add: 74, del: 41 },
|
||||
{ primary: "Run", secondary: "npm run freeze", mono: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function Dot({ tone }: { tone: string }) {
|
||||
return (
|
||||
<span className={`flex size-3.5 shrink-0 items-center justify-center rounded-full text-white ${tone}`}>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3.5 12h17M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const TONES = ["bg-accent", "bg-orange", "bg-green"];
|
||||
|
||||
export default function ThinkingState({ variant = "Steps", onSettled }: { variant?: string; onSettled?: () => void }) {
|
||||
const stage = useSequence(STAGES);
|
||||
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
|
||||
const [selectedTool, setSelectedTool] = useState<string | null>(null);
|
||||
const v = VARIANTS[variant] ?? VARIANTS.Steps;
|
||||
const autoExpanded = stage >= 1 && stage < 4;
|
||||
const expanded = manualExpanded ?? autoExpanded;
|
||||
const working = stage < 3;
|
||||
const visible = stage < 2 ? 0 : stage === 2 ? Math.min(2, v.rows.length) : v.rows.length;
|
||||
const traceRef = useRef<HTMLDivElement>(null);
|
||||
const [lineHeight, setLineHeight] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
if (traceRef.current) setLineHeight(traceRef.current.offsetHeight);
|
||||
}, [visible, expanded, variant, stage]);
|
||||
|
||||
/* let embedders sequence content after the trace settles */
|
||||
const settledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (working || settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
onSettled?.();
|
||||
}, [working, onSettled]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={variant}
|
||||
className="flex w-full max-w-95 flex-col"
|
||||
style={{
|
||||
minHeight: working || expanded ? 176 : undefined,
|
||||
transition: "min-height 400ms cubic-bezier(0.23,1,0.32,1)",
|
||||
}}
|
||||
>
|
||||
{/* header — shared across variants */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setManualExpanded((current) => !(current ?? autoExpanded))}
|
||||
className="-mx-1.5 flex w-fit items-center gap-2 rounded-control px-1.5 py-1
|
||||
transition-colors duration-100 hover:bg-hover-2"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={working ? "var(--ink-2)" : "var(--ink-3)"}>
|
||||
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
|
||||
</svg>
|
||||
<span role="status" className="contents">
|
||||
{working ? (
|
||||
<span
|
||||
className="bg-clip-text text-[13px] font-medium whitespace-nowrap text-transparent"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer-text 1.4s linear infinite",
|
||||
}}
|
||||
>
|
||||
{v.active}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-[13px] font-medium whitespace-nowrap text-ink-2"
|
||||
style={{ animation: "fade-in 350ms ease-out both" }}
|
||||
>
|
||||
{v.done}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="transition-transform duration-300"
|
||||
style={{ transform: expanded ? "rotate(180deg)" : "rotate(0)" }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* expandable trace */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-400"
|
||||
style={{
|
||||
gridTemplateRows: expanded ? "1fr" : "0fr",
|
||||
opacity: expanded ? 1 : 0,
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="relative mt-1 ml-[5px] pl-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[3px] w-px bg-line"
|
||||
style={{ top: -8, height: lineHeight ? lineHeight - 2 : 0, transition: "height 500ms cubic-bezier(0.23,1,0.32,1)" }}
|
||||
/>
|
||||
<div ref={traceRef} className="flex flex-col gap-1 py-1">
|
||||
{v.query && (
|
||||
<div className="flex h-6 items-center gap-2 px-1.5" style={{ animation: expanded ? "fade-up 300ms cubic-bezier(0.23,1,0.32,1) both" : undefined }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" className="shrink-0">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M21 21l-4.3-4.3" />
|
||||
</svg>
|
||||
<span className="text-[12.5px] text-ink-2">{v.query}</span>
|
||||
</div>
|
||||
)}
|
||||
{v.rows.slice(0, visible).map((row, i) => {
|
||||
const content = (
|
||||
<>
|
||||
{variant === "Search" && <Dot tone={TONES[i % 3]} />}
|
||||
{variant === "Steps" && (
|
||||
i < visible - 1 || !working ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className="size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2" style={{ animation: "spin 700ms linear infinite" }} />
|
||||
)
|
||||
)}
|
||||
<span className={`min-w-0 truncate text-[12.5px] ${variant === "Reasoning" ? "whitespace-normal leading-relaxed text-ink-2" : "font-medium text-ink"} ${variant === "Search" ? "animated-underline" : ""}`}>
|
||||
{row.primary}
|
||||
</span>
|
||||
{row.secondary && (
|
||||
<span className={`shrink-0 text-[11.5px] text-ink-3 ${row.mono ? "font-mono" : ""}`}>
|
||||
{row.secondary}
|
||||
</span>
|
||||
)}
|
||||
{row.add !== undefined && (
|
||||
<span className="shrink-0 font-mono text-[11px] tabular-nums">
|
||||
<span className="text-green">+{row.add}</span>{" "}
|
||||
<span className="text-red">−{row.del}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const rowClass = "flex min-h-7 w-full items-center gap-2 rounded-[6px] px-1.5 py-0.5 text-left";
|
||||
const animation = { animation: `fade-up 320ms cubic-bezier(0.23,1,0.32,1) ${i * 120}ms both` };
|
||||
|
||||
if (variant === "Search") {
|
||||
return (
|
||||
<a
|
||||
key={row.primary}
|
||||
href={row.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`${rowClass} transition-colors duration-150 hover:bg-hover`}
|
||||
style={animation}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "Coding") {
|
||||
const selected = selectedTool === row.primary;
|
||||
return (
|
||||
<button
|
||||
key={row.primary}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => setSelectedTool(selected ? null : row.primary)}
|
||||
className={`${rowClass} transition-colors duration-150 ${selected ? "bg-inset" : "hover:bg-hover"}`}
|
||||
style={animation}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={row.primary} className={rowClass} style={animation}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{variant === "Search" && stage >= 3 && (
|
||||
<span className="text-[12px] text-ink-3" style={{ animation: "fade-in 300ms ease-out both" }}>
|
||||
+7 more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* TOOL CHIPS
|
||||
* An agent run as compact rows: tool calls with inline
|
||||
* chips, then file-diff chips summarizing the edits.
|
||||
* Hover a row to reveal its chevron; every row expands
|
||||
* to show what the tool actually did.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const STEP_MS = 700;
|
||||
|
||||
const Icons: Record<string, React.ReactNode> = {
|
||||
think: <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />,
|
||||
write: <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z" /></g>,
|
||||
run: <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 17l6-5-6-5M12 19h8" /></g>,
|
||||
read: <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></g>,
|
||||
};
|
||||
|
||||
export type ToolDetailLine = { text: string; tone?: "add" };
|
||||
|
||||
export type ToolStep = {
|
||||
icon: string;
|
||||
label: string;
|
||||
chip: string;
|
||||
mono: boolean;
|
||||
detailMono: boolean;
|
||||
detail: ToolDetailLine[];
|
||||
};
|
||||
|
||||
export type ToolDiff = { file: string; add: number; del: number };
|
||||
|
||||
export type ToolDiffLine = { text: string; tone: "add" | "del" | "ctx" };
|
||||
|
||||
export type ToolChipsLabels = {
|
||||
header: string;
|
||||
more: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: ToolChipsLabels = {
|
||||
header: "4 tool calls, 2 messages",
|
||||
more: "+2 more",
|
||||
};
|
||||
|
||||
const ROWS: ToolStep[] = [
|
||||
{
|
||||
icon: "think", label: "Thinking", chip: "Planning the churn schedule…", mono: false, detailMono: false,
|
||||
detail: [
|
||||
{ text: "Weekend demand carries pistachio, so it churns first." },
|
||||
{ text: "Batch capacity leaves two evening freezer windows." },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "write", label: "Write 204 lines", chip: "ChurnSchedule.tsx", mono: true, detailMono: true,
|
||||
detail: [
|
||||
{ text: "+ const windows = slots.filter((s) => s.temp <= -12)", tone: "add" },
|
||||
{ text: "+ return schedule(windows, { hero: \"pistachio\" })", tone: "add" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "run", label: "Rebuild and verify", chip: "npm run freeze", mono: true, detailMono: true,
|
||||
detail: [
|
||||
{ text: "✓ built in 1.2s" },
|
||||
{ text: "✓ 34 checks passed" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "read", label: "Read image", chip: "flavor-chart.png", mono: true, detailMono: false,
|
||||
detail: [
|
||||
{ text: "1280 × 720 · line chart, three summers." },
|
||||
{ text: "Mint chip trends up 12% through July." },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const DIFFS: ToolDiff[] = [
|
||||
{ file: "flavors.css", add: 13, del: 0 },
|
||||
{ file: "ChurnSchedule.tsx", add: 74, del: 41 },
|
||||
{ file: "menu.ts", add: 8, del: 2 },
|
||||
];
|
||||
|
||||
/* hovering a file chip opens its diff — green added, red removed */
|
||||
const DIFF_LINES: Record<string, ToolDiffLine[]> = {
|
||||
"flavors.css": [
|
||||
{ text: ".scoop-card {", tone: "ctx" },
|
||||
{ text: " gap: 14px;", tone: "del" },
|
||||
{ text: " gap: 12px;", tone: "add" },
|
||||
{ text: " container-type: inline-size;", tone: "add" },
|
||||
{ text: "}", tone: "ctx" },
|
||||
],
|
||||
"ChurnSchedule.tsx": [
|
||||
{ text: "const slots = coldSlots(week);", tone: "ctx" },
|
||||
{ text: "const windows = slots;", tone: "del" },
|
||||
{ text: "const windows = slots.filter(", tone: "add" },
|
||||
{ text: " (s) => s.temp <= -12,", tone: "add" },
|
||||
{ text: ");", tone: "add" },
|
||||
],
|
||||
"menu.ts": [
|
||||
{ text: "export const hero = \"mint-chip\";", tone: "del" },
|
||||
{ text: "export const hero = \"pistachio\";", tone: "add" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function ToolChips({
|
||||
steps = ROWS,
|
||||
diffs = DIFFS,
|
||||
diffLines = DIFF_LINES,
|
||||
labels,
|
||||
className,
|
||||
onOpenChange,
|
||||
onToggleRow,
|
||||
}: {
|
||||
/** Accepted for gallery/registry parity; ToolChips has no visual variants. */
|
||||
variant?: string;
|
||||
steps?: ToolStep[];
|
||||
diffs?: ToolDiff[];
|
||||
diffLines?: Record<string, ToolDiffLine[]>;
|
||||
labels?: Partial<ToolChipsLabels>;
|
||||
className?: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onToggleRow?: (label: string, open: boolean) => void;
|
||||
} = {}) {
|
||||
const copy = { ...DEFAULT_LABELS, ...labels };
|
||||
const [step, setStep] = useState(0);
|
||||
const [open, setOpen] = useState(true);
|
||||
const [openRows, setOpenRows] = useState<Set<string>>(new Set());
|
||||
/* Rendered in a body portal so animated/translated reply wrappers cannot
|
||||
* redefine the fixed-position coordinate system. */
|
||||
const [preview, setPreview] = useState<{
|
||||
file: string;
|
||||
x: number;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
} | null>(null);
|
||||
const openPreview = (file: string) => (event: React.SyntheticEvent) => {
|
||||
const rect = (event.currentTarget as Element).closest("[data-diffchip]")!.getBoundingClientRect();
|
||||
const previewHeight = 38 + (diffLines[file]?.length ?? 0) * 19;
|
||||
const fitsBelow = rect.bottom + 6 + previewHeight <= window.innerHeight - 12;
|
||||
setPreview({
|
||||
file,
|
||||
x: Math.max(12, Math.min(rect.left, window.innerWidth - 300)),
|
||||
...(fitsBelow
|
||||
? { top: rect.bottom + 6 }
|
||||
: { bottom: window.innerHeight - rect.top + 6 }),
|
||||
});
|
||||
};
|
||||
const closePreview = (file: string) => () =>
|
||||
setPreview((current) => (current?.file === file ? null : current));
|
||||
const total = steps.length + 1; // rows, then diff chips
|
||||
|
||||
useEffect(() => {
|
||||
if (step >= total) return;
|
||||
const t = setTimeout(() => setStep((s) => s + 1), STEP_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [step, total]);
|
||||
|
||||
const toggleRow = (label: string) =>
|
||||
setOpenRows((current) => {
|
||||
const next = new Set(current);
|
||||
next.has(label) ? next.delete(label) : next.add(label);
|
||||
onToggleRow?.(label, next.has(label));
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`min-h-[220px] w-full max-w-80 pb-1${className ? ` ${className}` : ""}`}>
|
||||
{/* collapsed run header */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() =>
|
||||
setOpen((current) => {
|
||||
onOpenChange?.(!current);
|
||||
return !current;
|
||||
})
|
||||
}
|
||||
className="-mx-1.5 flex w-fit items-center gap-1.5 rounded-control px-1.5 py-1 text-[12.5px] text-ink-2 transition-colors duration-100 hover:bg-hover-2"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className="transition-transform duration-200" style={{ transform: open ? "rotate(0deg)" : "rotate(-90deg)" }}>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
<span className="tabular-nums">{copy.header}</span>
|
||||
</button>
|
||||
|
||||
{/* tool call rows */}
|
||||
<div className="grid transition-[grid-template-rows,opacity] duration-300" style={{ gridTemplateRows: open ? "1fr" : "0fr", opacity: open ? 1 : 0 }}>
|
||||
{/* -mx-1 + px-1.5 keeps content at the same x while giving the
|
||||
row hover pills room inside this overflow-hidden clip box */}
|
||||
<div className="-mx-1 overflow-hidden px-1.5 pb-1">
|
||||
<div className="mt-1.5 flex flex-col gap-1">
|
||||
{steps.slice(0, step).map((row) => {
|
||||
const rowOpen = openRows.has(row.label);
|
||||
return (
|
||||
<div key={row.label} style={{ animation: "fade-up 300ms cubic-bezier(0.23,1,0.32,1) both" }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={rowOpen}
|
||||
onClick={() => toggleRow(row.label)}
|
||||
className="group/row -mx-[3px] flex h-7 w-[calc(100%+6px)] min-w-0 items-center gap-2 rounded-control px-[3px] text-left transition-colors duration-100 hover:bg-hover-2"
|
||||
>
|
||||
<span className="relative flex size-4 shrink-0 items-center justify-center text-ink-3">
|
||||
<svg
|
||||
width="13" height="13" viewBox="0 0 24 24" fill={row.icon === "think" ? "currentColor" : "none"} stroke="currentColor"
|
||||
className={`transition-opacity duration-100 group-hover/row:opacity-0 ${rowOpen ? "opacity-0" : ""}`}
|
||||
>
|
||||
{Icons[row.icon]}
|
||||
</svg>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className={`absolute transition-[opacity,transform] duration-150 group-hover/row:opacity-100 ${rowOpen ? "opacity-100" : "opacity-0"}`}
|
||||
style={{ transform: rowOpen ? "rotate(0deg)" : "rotate(-90deg)" }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="shrink-0 text-[12.5px] font-medium text-ink">{row.label}</span>
|
||||
<span
|
||||
className={`inline-flex h-5.5 min-w-0 flex-1 cursor-pointer items-center truncate rounded-chip bg-field px-1.5
|
||||
text-[11.5px] text-ink-2 shadow-hairline transition-colors duration-100 hover:bg-hover-2
|
||||
${row.mono ? "font-mono" : ""}`}
|
||||
>
|
||||
{row.chip}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* expanded detail */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-300"
|
||||
style={{ gridTemplateRows: rowOpen ? "1fr" : "0fr", opacity: rowOpen ? 1 : 0, transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)" }}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className="mt-0.5 mb-1 ml-2 flex flex-col gap-0.5 border-l border-line py-0.5 pl-3.5">
|
||||
{row.detail.map((line) => (
|
||||
<span
|
||||
key={line.text}
|
||||
className={`truncate text-[11.5px] leading-[1.6] ${row.detailMono ? "font-mono" : ""} ${line.tone === "add" ? "text-green" : "text-ink-2"}`}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* file-diff chips */}
|
||||
{step >= total && (
|
||||
<div className="mt-2.5 flex max-w-full flex-wrap gap-1.5 border-t border-line pt-2.5">
|
||||
{diffs.map((d, i) => (
|
||||
<span
|
||||
key={d.file}
|
||||
data-diffchip
|
||||
className="relative"
|
||||
onMouseEnter={openPreview(d.file)}
|
||||
onMouseLeave={closePreview(d.file)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={preview?.file === d.file}
|
||||
aria-label={`Show diff for ${d.file}`}
|
||||
onFocus={openPreview(d.file)}
|
||||
onBlur={closePreview(d.file)}
|
||||
className="inline-flex h-7 max-w-full items-center gap-2 rounded-chip
|
||||
bg-surface px-2 font-mono text-[11.5px] text-ink shadow-btn
|
||||
transition-colors duration-100 hover:bg-hover"
|
||||
style={{ animation: `pop-in 250ms cubic-bezier(0.23,1,0.32,1) ${i * 80}ms both` }}
|
||||
>
|
||||
<span className="min-w-0 truncate">{d.file}</span>
|
||||
<span className="shrink-0 text-green tabular-nums">+{d.add}</span>
|
||||
{d.del > 0 && <span className="shrink-0 text-red tabular-nums">−{d.del}</span>}
|
||||
</button>
|
||||
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 items-center rounded-chip px-1.5 font-mono text-[11.5px] text-ink-3
|
||||
underline decoration-transparent underline-offset-2 transition-colors duration-100
|
||||
hover:text-ink-2 hover:decoration-current"
|
||||
style={{ animation: `fade-in 300ms ease-out ${diffs.length * 80}ms both` }}
|
||||
>
|
||||
{copy.more}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{preview && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
className="fixed z-50 w-72 overflow-hidden rounded-[10px] bg-surface shadow-overlay"
|
||||
style={{
|
||||
left: preview.x,
|
||||
top: preview.top,
|
||||
bottom: preview.bottom,
|
||||
animation: "pop-in 160ms cubic-bezier(0.23,1,0.32,1) both",
|
||||
transformOrigin: preview.top === undefined ? "bottom left" : "top left",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-line px-2.5 py-1.5 font-mono text-[11px]">
|
||||
<span className="min-w-0 truncate text-ink-2">{preview.file}</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
<span className="text-green">+{diffs.find((diff) => diff.file === preview.file)?.add}</span>
|
||||
{(diffs.find((diff) => diff.file === preview.file)?.del ?? 0) > 0 && (
|
||||
<span className="text-red"> −{diffs.find((diff) => diff.file === preview.file)?.del}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-1 font-mono text-[11px] leading-[1.8]">
|
||||
{(diffLines[preview.file] ?? []).map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex gap-2 px-2.5 whitespace-pre ${
|
||||
line.tone === "add"
|
||||
? "bg-green-tint text-green"
|
||||
: line.tone === "del"
|
||||
? "bg-red-tint text-red"
|
||||
: "text-ink-2"
|
||||
}`}
|
||||
>
|
||||
<span className="w-3 shrink-0 select-none">{line.tone === "add" ? "+" : line.tone === "del" ? "−" : " "}</span>
|
||||
<span className="min-w-0 truncate">{line.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-643
@@ -1,643 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
/* shadow-plugin (flornkm) — smooth, layered shadow stacks. The unprefixed
|
||||
* entry maps them onto Tailwind's --shadow-xs…2xl scale; our semantic tokens
|
||||
* below build on those values so the whole app picks up the smooth shadows. */
|
||||
@import "shadow-plugin/unprefixed";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* GENERIC FOUNDATION — tokens, @theme, base rules, the primitive-*
|
||||
* spacing utilities, and shared keyframes below are needed by EVERY
|
||||
* component. Blocks tagged COMPONENT-SPECIFIC further down belong to
|
||||
* one primitive each. */
|
||||
/* ──────────────────────────────────────────────────────────
|
||||
* Beautiful UI — design tokens
|
||||
* Cool near-white canvas, white cards, hairline borders, layered
|
||||
* single-digit-opacity shadows, a neutral ink ramp, and semantic
|
||||
* color used sparingly as a condiment.
|
||||
* ────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
/* surfaces — cool, blue-tinted neutrals */
|
||||
--page: oklch(0.985 0.001 286.376);
|
||||
--canvas: oklch(0.961 0.002 247.84);
|
||||
--surface: oklch(1 0 0);
|
||||
--inset: oklch(0.979 0.002 247.839);
|
||||
--hover: oklch(0.97 0.002 247.839);
|
||||
--hover-2: oklch(0.933 0.003 247.86);
|
||||
|
||||
/* ink ramp */
|
||||
--ink: oklch(0.247 0.006 258.361);
|
||||
--ink-2: oklch(0.506 0.01 264.477);
|
||||
--ink-3: oklch(0.695 0.009 264.505);
|
||||
|
||||
/* borders — solid and crisp, not alpha */
|
||||
--line: oklch(0.946 0.003 264.542);
|
||||
--line-strong: oklch(0.912 0.005 258.326);
|
||||
--line-soft: oklch(0.966 0.002 264.542);
|
||||
--grid-line: color-mix(in srgb, var(--line) 78%, transparent);
|
||||
--field: oklch(0.961 0.001 286.375);
|
||||
--stripe: oklch(0.405 0 0 / 0.075);
|
||||
--stripe-bg: oklch(0.97 0 0);
|
||||
|
||||
/* accent */
|
||||
--accent: oklch(0.626 0.205 254.947);
|
||||
--accent-ink: oklch(0.556 0.187 255.617);
|
||||
--accent-tint: oklch(0.96 0.019 252.878);
|
||||
|
||||
/* semantic */
|
||||
--green: oklch(0.603 0.155 150.883);
|
||||
--green-tint: oklch(0.958 0.017 159.118);
|
||||
--orange: oklch(0.689 0.179 49.902);
|
||||
--orange-tint: oklch(0.964 0.021 67.581);
|
||||
--red: oklch(0.621 0.192 23.042);
|
||||
--red-tint: oklch(0.956 0.017 17.462);
|
||||
|
||||
/* chart overlays — neutral system colors with enough contrast on either theme */
|
||||
--tooltip-bg: oklch(0.272 0.008 264.435);
|
||||
--tooltip-fg: oklch(0.976 0.002 247.839);
|
||||
--tooltip-muted: oklch(0.731 0.008 260.731);
|
||||
--tooltip-border: oklch(0.356 0.007 264.474);
|
||||
|
||||
/* shadows — our solid hairline ring + shadow-plugin's smooth layered stacks
|
||||
* (--shadow-xs…lg come from shadow-plugin/unprefixed) so every surface reads
|
||||
* consistently across the app. */
|
||||
--shadow-hairline: 0 0 0 1px var(--line);
|
||||
--shadow-btn: 0 0 0 1px var(--line-strong), var(--shadow-xs);
|
||||
--shadow-card: 0 0 0 1px var(--line), var(--shadow-sm);
|
||||
--shadow-raised: 0 0 0 1px var(--line), var(--shadow-md);
|
||||
--shadow-overlay: 0 0 0 1px var(--line), var(--shadow-lg);
|
||||
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.12);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--page: oklch(0.209 0.004 264.477);
|
||||
--canvas: oklch(0.231 0.004 264.487);
|
||||
--surface: oklch(0.26 0.006 271.191);
|
||||
--inset: oklch(0.243 0.004 264.492);
|
||||
--hover: oklch(0.289 0.006 271.22);
|
||||
--hover-2: oklch(0.318 0.007 274.747);
|
||||
|
||||
--ink: oklch(0.964 0.002 247.839);
|
||||
--ink-2: oklch(0.731 0.008 260.731);
|
||||
--ink-3: oklch(0.541 0.01 264.484);
|
||||
|
||||
--line: oklch(0.308 0.006 258.354);
|
||||
--line-strong: oklch(0.356 0.007 264.474);
|
||||
--line-soft: oklch(0.278 0.006 258.354);
|
||||
--field: oklch(0.293 0.006 271.223);
|
||||
--stripe: oklch(1 0 0 / 0.055);
|
||||
--stripe-bg: oklch(0.226 0.004 264.485);
|
||||
|
||||
--accent: oklch(0.68 0.173 253.301);
|
||||
--accent-ink: oklch(0.788 0.113 248.33);
|
||||
--accent-tint: oklch(0.68 0.173 253.301 / 0.16);
|
||||
|
||||
--green: oklch(0.705 0.154 153.814);
|
||||
--green-tint: oklch(0.705 0.154 153.814 / 0.14);
|
||||
--orange: oklch(0.746 0.156 55.642);
|
||||
--orange-tint: oklch(0.746 0.156 55.642 / 0.14);
|
||||
--red: oklch(0.666 0.18 21.433);
|
||||
--red-tint: oklch(0.666 0.18 21.433 / 0.14);
|
||||
|
||||
--tooltip-bg: oklch(0.182 0.004 264.459);
|
||||
--tooltip-fg: oklch(0.964 0.002 247.839);
|
||||
--tooltip-muted: oklch(0.731 0.008 260.731);
|
||||
--tooltip-border: oklch(0.308 0.006 258.354);
|
||||
|
||||
--shadow-hairline: 0 0 0 1px var(--line);
|
||||
/* Elevation rings go to a subtle *light* hairline in dark mode: the ring
|
||||
* paints over the surface behind the element, so a low-alpha white edge
|
||||
* dissolves into the shadow as one continuous stroke (per smooth-shadow-ring),
|
||||
* graduated by how far the surface floats. */
|
||||
--shadow-btn: 0 0 0 1px oklch(1 0 0 / 0.1), 0 1px 2px oklch(0 0 0 / 0.3);
|
||||
--shadow-card:
|
||||
0 0 0 1px oklch(1 0 0 / 0.11), 0 1px 2px oklch(0 0 0 / 0.2),
|
||||
0 2px 6px oklch(0 0 0 / 0.2);
|
||||
--shadow-raised:
|
||||
0 0 0 1px oklch(1 0 0 / 0.13), 0 2px 10px oklch(0 0 0 / 0.22);
|
||||
--shadow-overlay:
|
||||
0 0 0 1px oklch(1 0 0 / 0.15), 0 8px 28px oklch(0 0 0 / 0.34);
|
||||
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* colors */
|
||||
--color-page: var(--page);
|
||||
--color-canvas: var(--canvas);
|
||||
--color-surface: var(--surface);
|
||||
--color-inset: var(--inset);
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-2: var(--ink-2);
|
||||
--color-ink-3: var(--ink-3);
|
||||
--color-hover: var(--hover);
|
||||
--color-hover-2: var(--hover-2);
|
||||
--color-field: var(--field);
|
||||
--color-line: var(--line);
|
||||
--color-line-strong: var(--line-strong);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-ink: var(--accent-ink);
|
||||
--color-accent-tint: var(--accent-tint);
|
||||
--color-green: var(--green);
|
||||
--color-green-tint: var(--green-tint);
|
||||
--color-orange: var(--orange);
|
||||
--color-orange-tint: var(--orange-tint);
|
||||
--color-red: var(--red);
|
||||
--color-red-tint: var(--red-tint);
|
||||
|
||||
/* shadows */
|
||||
--shadow-hairline: var(--shadow-hairline);
|
||||
--shadow-btn: var(--shadow-btn);
|
||||
--shadow-inset-field: var(--shadow-inset-field);
|
||||
--shadow-card: var(--shadow-card);
|
||||
--shadow-raised: var(--shadow-raised);
|
||||
--shadow-overlay: var(--shadow-overlay);
|
||||
|
||||
/* type */
|
||||
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-mono-face), ui-monospace, "SF Mono", monospace;
|
||||
|
||||
/* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */
|
||||
--radius-chip: 6px;
|
||||
--radius-control: 8px;
|
||||
--radius-card: 10px;
|
||||
--radius-window: 14px;
|
||||
|
||||
/* easing — strong curves; built-ins are too weak */
|
||||
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-link: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ── base ─────────────────────────────────────────────── */
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--stripe-bg);
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent 0,
|
||||
transparent 7px,
|
||||
var(--stripe) 7px,
|
||||
var(--stripe) 8px
|
||||
);
|
||||
/* Pin the stripe pattern to the viewport so it stays put on scroll. */
|
||||
background-attachment: fixed;
|
||||
color: var(--ink);
|
||||
font-feature-settings: "cv11", "ss01";
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-tint);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ── interface hygiene (interfaces.dev cheat sheet) ────── */
|
||||
|
||||
/* headings balance; descriptions avoid orphans (skip long-form — we have none) */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* underlines clear descenders */
|
||||
a {
|
||||
text-underline-position: from-font;
|
||||
text-decoration-skip-ink: auto;
|
||||
}
|
||||
|
||||
/* theme switches swap every token at once — freeze transitions so the flip
|
||||
* is one clean repaint instead of hundreds of mismatched color fades */
|
||||
.theme-switching *,
|
||||
.theme-switching *::before,
|
||||
.theme-switching *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* touch: :hover sticks after a tap, so neutralize row/header hovers there */
|
||||
@media (hover: none) {
|
||||
.records-row:hover > .records-cell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.records-row:hover > td.is-colsel {
|
||||
background: color-mix(in srgb, var(--accent) 4%, var(--surface));
|
||||
}
|
||||
|
||||
.records-header-button:hover {
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the component nav scrollable but hide its scrollbar. */
|
||||
.component-nav-scroll {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* legacy Edge */
|
||||
}
|
||||
.component-nav-scroll::-webkit-scrollbar {
|
||||
display: none; /* WebKit */
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.component-nav-scroll {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
oklch(0 0 0) 0,
|
||||
oklch(0 0 0) calc(100% - 6rem),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
oklch(0 0 0) 0,
|
||||
oklch(0 0 0) calc(100% - 6rem),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile demo gestures should operate controls and visualizations rather than
|
||||
* selecting surrounding labels. Text entry and code remain intentionally
|
||||
* selectable. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.primitive-showcase {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(input, textarea, [contenteditable="true"], pre, code) {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(a, button, [role="button"]) {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(a, button, [role="button"]):focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(img, svg, canvas) {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.insight-chart-stage {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tailwind v4 resets buttons to cursor:default — anything
|
||||
* clickable must invite the click */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
label:has(input),
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible,
|
||||
[contenteditable]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.animated-underline {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.animated-underline::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
content: "";
|
||||
background: currentColor;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform 280ms var(--ease-link);
|
||||
}
|
||||
|
||||
a:hover .animated-underline::after,
|
||||
a:focus-visible .animated-underline::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — StreamingText / ContextCards
|
||||
* Favicon source avatars.
|
||||
* Copy this block only alongside StreamingText / ContextCards; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.source-avatar {
|
||||
box-shadow: 0 0 0 1px oklch(0.21 0.034 263.436 / 0.1);
|
||||
}
|
||||
|
||||
.dark .source-avatar {
|
||||
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* ── primitive spacing system ─────────────────────────────
|
||||
* Shared optical offsets for small demo cards. Use these
|
||||
* instead of hand-tuning 10/12/14px per component.
|
||||
*/
|
||||
|
||||
.primitive-card-pad {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.primitive-card-bar {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.primitive-card-footer {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.primitive-table-cell {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.primitive-icon-button {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
/* ── shared keyframes ─────────────────────────────────── */
|
||||
|
||||
@keyframes shimmer-text {
|
||||
from {
|
||||
background-position: 150% center;
|
||||
}
|
||||
to {
|
||||
background-position: -50% center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.home-reveal {
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
|
||||
/* The sidebar is one persistent 224px tree clipped by a 52px shell.
|
||||
* Every rail glyph remains centered at x=26 in both states. */
|
||||
.records-footer-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── filter table electric status colors ─────────────────── */
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — FilterTable
|
||||
* Electric status-pill colors.
|
||||
* Copy this block only alongside FilterTable; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.filter-status-todo,
|
||||
.filter-status-progress,
|
||||
.filter-status-done {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — InsightCards
|
||||
* Liveline chart tooltip + cursor.
|
||||
* Copy this block only alongside InsightCards; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.insight-chart-stage {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.insight-chart-cursor {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--ink);
|
||||
opacity: 0.26;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-anchor {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 8px;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--tooltip-fg);
|
||||
background: var(--tooltip-bg);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Status pills share the same base-hue → color-mix derivation as .records-tag,
|
||||
* so both components stay consistent across light and dark. */
|
||||
.filter-status-todo {
|
||||
--tag-base: oklch(0.757 0.153 66.401);
|
||||
}
|
||||
.filter-status-progress {
|
||||
--tag-base: oklch(0.671 0.118 219.351);
|
||||
}
|
||||
.filter-status-done {
|
||||
--tag-base: oklch(0.652 0.131 162.865);
|
||||
}
|
||||
|
||||
.filter-status-todo,
|
||||
.filter-status-progress,
|
||||
.filter-status-done {
|
||||
color: color-mix(in srgb, var(--tag-base) 92%, var(--ink));
|
||||
background: color-mix(in srgb, var(--tag-base) 20%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
}
|
||||
|
||||
/* Dark mode — a richer, saturated same-hue fill
|
||||
* with bright same-hue text and no visible border (light mode keeps the soft
|
||||
* tinted pastels above). Applies to both the records grid and status pills. */
|
||||
.dark .records-tag,
|
||||
.dark .filter-status-todo,
|
||||
.dark .filter-status-progress,
|
||||
.dark .filter-status-done {
|
||||
color: color-mix(in srgb, var(--tag-base) 86%, white);
|
||||
background: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes eq-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.35);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── StreamText primitive ─────────────────────────────────
|
||||
* Fast character reveal: the newest few characters carry a
|
||||
* soft, masked blur so the leading edge resolves smoothly as
|
||||
* text flows in. The caret holds solid while streaming, then
|
||||
* blinks once the string settles. */
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — StreamText
|
||||
* The streaming caret + tail.
|
||||
* Copy this block only alongside StreamText; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.stream-tail {
|
||||
filter: blur(1.6px);
|
||||
-webkit-mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
|
||||
mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
|
||||
}
|
||||
|
||||
.stream-caret {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1.05em;
|
||||
margin-left: 1.5px;
|
||||
translate: 0 -0.5px;
|
||||
border-radius: 1px;
|
||||
background: var(--ink);
|
||||
vertical-align: text-bottom;
|
||||
animation: caret-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
.stream-caret.is-streaming {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes caret-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stream-caret {
|
||||
animation: none;
|
||||
}
|
||||
.stream-tail {
|
||||
filter: none;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* pixel-grid loader — brighten plateau, staggered per cell */
|
||||
@keyframes pixel-on {
|
||||
0%, 100% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
18%, 42% {
|
||||
opacity: 1;
|
||||
}
|
||||
62% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — SidebarNav
|
||||
* The 52↔224px collapsible sidebar tree.
|
||||
* Copy this block only alongside SidebarNav; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.sidebar-row {
|
||||
width: 208px;
|
||||
transition-duration: var(--sidebar-copy-duration);
|
||||
transition-timing-function: var(--sidebar-easing);
|
||||
}
|
||||
|
||||
.sidebar-copy {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition:
|
||||
opacity var(--sidebar-copy-duration) ease-out,
|
||||
transform var(--sidebar-copy-duration) var(--sidebar-easing);
|
||||
}
|
||||
|
||||
.sidebar-logo,
|
||||
.sidebar-collapse-control,
|
||||
.sidebar-expand-control {
|
||||
transition-duration: var(--sidebar-copy-duration);
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.sidebar-expand-control {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-glide-highlight {
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-row {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-copy {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateX(calc(var(--sidebar-copy-offset) * -1));
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-workspace-control {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-logo,
|
||||
[data-sidebar-collapsed="true"] .sidebar-collapse-control {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-expand-control {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-glide-highlight {
|
||||
right: auto;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-reveal {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── records table ────────────────────────────────────────
|
||||
* The grid is deliberately more architectural than card-like:
|
||||
* one quiet surface, precise rules, and color reserved for
|
||||
* meaning. The first column remains an anchor while both axes
|
||||
* can move independently inside the table viewport.
|
||||
*/
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "stream-text",
|
||||
"type": "registry:ui",
|
||||
"title": "StreamText",
|
||||
"description": "Shared building block used by other Beautiful UI components.",
|
||||
"registryDependencies": [
|
||||
"https://www.beautifului.dev/r/foundation.json"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "components/atoms/StreamText.tsx",
|
||||
"content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * STREAM TEXT — reusable streaming primitive\n * Reveals characters quickly (fast, like a real token stream);\n * the leading edge resolves out of a soft blur, and the caret\n * stays solid while streaming, then blinks once the text\n * settles. Inherits typography from its context, so it drops\n * into any text surface (Selection Actions, chat, etc.).\n * ───────────────────────────────────────────────────────── */\n\nexport function StreamText({\n text,\n charsPerTick = 2,\n tickMs = 9,\n blurTail = 6,\n caret = true,\n className,\n onProgress,\n onDone,\n}: {\n text: string;\n /** characters revealed per tick — higher is faster */\n charsPerTick?: number;\n /** interval between reveals, ms */\n tickMs?: number;\n /** how many trailing characters carry the soft blur edge */\n blurTail?: number;\n /** render the caret (solid while streaming, blinks once idle) */\n caret?: boolean;\n className?: string;\n /** fires each tick — useful for re-anchoring UI to reflowing text */\n onProgress?: () => void;\n /** fires once the full string is shown */\n onDone?: () => void;\n}) {\n const [count, setCount] = useState(0);\n const onProgressRef = useRef(onProgress);\n const onDoneRef = useRef(onDone);\n onProgressRef.current = onProgress;\n onDoneRef.current = onDone;\n\n useEffect(() => {\n setCount(0);\n let i = 0;\n const id = setInterval(() => {\n i = Math.min(i + charsPerTick, text.length);\n setCount(i);\n onProgressRef.current?.();\n if (i >= text.length) {\n clearInterval(id);\n onDoneRef.current?.();\n }\n }, tickMs);\n return () => clearInterval(id);\n }, [text, charsPerTick, tickMs]);\n\n const streaming = count < text.length;\n const shown = text.slice(0, count);\n const split = streaming ? Math.max(0, shown.length - blurTail) : shown.length;\n\n return (\n <span className={className}>\n {shown.slice(0, split)}\n {split < shown.length && (\n <span className=\"stream-tail\">{shown.slice(split)}</span>\n )}\n {caret && (\n <span\n aria-hidden\n className={`stream-caret${streaming ? \" is-streaming\" : \"\"}`}\n />\n )}\n </span>\n );\n}\n",
|
||||
"type": "registry:ui",
|
||||
"target": "components/atoms/StreamText.tsx"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,18 +0,0 @@
|
||||
// 校验 react-icons/lu 里要用的图标名是否存在
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
|
||||
const pnpm = new URL('../node_modules/.pnpm/', import.meta.url)
|
||||
const dir = readdirSync(pnpm).find((d) => d.startsWith('react-icons@'))
|
||||
if (!dir) throw new Error('react-icons not found')
|
||||
const dts = new URL(`${dir}/node_modules/react-icons/lu/index.d.ts`, pnpm)
|
||||
const content = readFileSync(dts, 'utf8')
|
||||
|
||||
const names = [
|
||||
'LuCheck', 'LuCpu', 'LuMessageSquare', 'LuPencil', 'LuRefreshCw', 'LuSettings', 'LuStore',
|
||||
'LuTrash2', 'LuX', 'LuAlertTriangle', 'LuTriangleAlert', 'LuRotateCw', 'LuShieldAlert',
|
||||
'LuTimer', 'LuKeyRound', 'LuLogOut', 'LuSearch', 'LuSparkles', 'LuPlus', 'LuArrowUp',
|
||||
'LuSquare', 'LuChevronDown', 'LuActivity'
|
||||
]
|
||||
for (const n of names) {
|
||||
console.log(n, content.includes(`declare const ${n}:`) ? 'OK' : 'MISSING')
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// 补抓 thinking-state + 解包全部 JSON 到暂存目录,并打印每个组件的 import / 导出签名
|
||||
import { mkdir, writeFile, readFile } from 'node:fs/promises'
|
||||
|
||||
const jsonDir = new URL('./beautifului/', import.meta.url)
|
||||
const stageDir = new URL('./beautifului/stage/', import.meta.url)
|
||||
await mkdir(stageDir, { recursive: true })
|
||||
|
||||
// 1) 补抓 thinking-state
|
||||
const tsRes = await fetch('https://www.beautifului.dev/r/thinking-state.json')
|
||||
if (tsRes.ok) await writeFile(new URL('thinking-state.json', jsonDir), await tsRes.text())
|
||||
else console.log('thinking-state => HTTP', tsRes.status)
|
||||
|
||||
// 2) 解包 + 签名摘要
|
||||
const slugs = [
|
||||
'foundation',
|
||||
'loading-state',
|
||||
'thinking-state',
|
||||
'streaming-text',
|
||||
'tool-chips',
|
||||
'approval-card',
|
||||
'button',
|
||||
'glide-menu'
|
||||
]
|
||||
|
||||
for (const s of slugs) {
|
||||
let raw
|
||||
try {
|
||||
raw = await readFile(new URL(`${s}.json`, jsonDir), 'utf8')
|
||||
} catch {
|
||||
console.log(`### ${s}: JSON 缺失`)
|
||||
continue
|
||||
}
|
||||
const j = JSON.parse(raw)
|
||||
console.log(`\n### ${s} (${j.type}) deps=${JSON.stringify(j.dependencies || [])}`)
|
||||
if (j.css) {
|
||||
await writeFile(new URL(`${s}.css`, stageDir), j.css)
|
||||
console.log(` css -> stage/${s}.css (${j.css.length} chars)`)
|
||||
}
|
||||
for (const f of j.files || []) {
|
||||
const name = f.path.split('/').pop()
|
||||
await writeFile(new URL(name, stageDir), f.content ?? '')
|
||||
console.log(` ${f.path} -> stage/${name} (${(f.content ?? '').length} chars)`)
|
||||
if (f.content) {
|
||||
const imports = f.content
|
||||
.split('\n')
|
||||
.filter((l) => /^(import|export)\b/.test(l.trim()) && /from ['"]/.test(l))
|
||||
for (const l of imports) console.log(' ', l.trim())
|
||||
const exports = f.content.match(/^export (function|const|interface|type|default)[^\n({]*/gm) || []
|
||||
for (const e of exports) console.log(' EXP:', e.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// 下载 beautifului.dev registry:索引 + 候选组件 JSON,保存到 scripts/beautifului/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
|
||||
const outDir = new URL('./beautifului/', import.meta.url)
|
||||
await mkdir(outDir, { recursive: true })
|
||||
|
||||
async function grab(slug) {
|
||||
const r = await fetch(`https://www.beautifului.dev/r/${slug}.json`)
|
||||
if (!r.ok) {
|
||||
console.log(slug, '=> HTTP', r.status)
|
||||
return null
|
||||
}
|
||||
const text = await r.text()
|
||||
await writeFile(new URL(`${slug}.json`, outDir), text)
|
||||
return JSON.parse(text)
|
||||
}
|
||||
|
||||
// 1) registry 索引
|
||||
const idx = await grab('registry')
|
||||
if (idx) {
|
||||
const items = Array.isArray(idx) ? idx : idx.items || []
|
||||
console.log('=== registry index ===')
|
||||
for (const it of items) console.log('-', it.name, '|', it.title || '', '|', it.type || '')
|
||||
}
|
||||
|
||||
// 2) 候选组件(含传递依赖)
|
||||
const wanted = [
|
||||
'foundation',
|
||||
'loading-state',
|
||||
'streaming-text',
|
||||
'tool-chips',
|
||||
'approval-card',
|
||||
'button',
|
||||
'glide-menu',
|
||||
'task-rows',
|
||||
'prompt-bar',
|
||||
'selection-actions',
|
||||
'shimmer',
|
||||
'stream-text'
|
||||
]
|
||||
console.log('=== components ===')
|
||||
for (const s of wanted) {
|
||||
const j = await grab(s)
|
||||
if (!j) continue
|
||||
const files = (j.files || []).map((f) => f.path).join(', ')
|
||||
console.log(`${s}: deps=${JSON.stringify(j.dependencies || [])} files=[${files}]`)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// 把 registry 原版组件复制进 renderer(保持 beautifului.dev 原始源码)
|
||||
import { mkdir, copyFile } from 'node:fs/promises'
|
||||
|
||||
const stage = new URL('./beautifului/stage/', import.meta.url)
|
||||
const root = new URL('../apps/desktop/src/renderer/src/components/', import.meta.url)
|
||||
|
||||
await mkdir(new URL('atoms/', root), { recursive: true })
|
||||
await mkdir(new URL('primitives/', root), { recursive: true })
|
||||
|
||||
const verbatim = [
|
||||
['Button.tsx', 'atoms/Button.tsx'],
|
||||
['GlideMenu.tsx', 'primitives/GlideMenu.tsx'],
|
||||
['LoadingState.tsx', 'primitives/LoadingState.tsx'],
|
||||
['ApprovalCard.tsx', 'primitives/ApprovalCard.tsx']
|
||||
]
|
||||
|
||||
for (const [from, to] of verbatim) {
|
||||
await copyFile(new URL(from, stage), new URL(to, root))
|
||||
console.log('copied', from, '->', to)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// 检查 pi-coding-agent 的 SessionManager / AgentSession 类型定义,确认会话落盘时机
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
||||
|
||||
const pnpm = new URL('../node_modules/.pnpm/', import.meta.url)
|
||||
const dir = readdirSync(pnpm).find((d) => d.startsWith('@earendil-works+pi-coding-agent@'))
|
||||
if (!dir) throw new Error('pi-coding-agent not found')
|
||||
const dist = new URL(`${dir}/node_modules/@earendil-works/pi-coding-agent/dist/`, pnpm)
|
||||
console.log('dist exists:', existsSync(dist))
|
||||
|
||||
// 在 dist 里 grep 关键定义
|
||||
const { execSync } = await import('node:child_process')
|
||||
const distPath = new URL('.', dist).pathname.replace(/^\/([A-Za-z]:)/, '$1')
|
||||
const grep = (pattern) => {
|
||||
try {
|
||||
const out = execSync(
|
||||
`findstr /s /n /c:"${pattern}" "${distPath}*.d.ts"`,
|
||||
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
|
||||
)
|
||||
console.log(`\n=== ${pattern} ===`)
|
||||
console.log(out.split('\n').slice(0, 40).join('\n'))
|
||||
} catch (e) {
|
||||
console.log(`\n=== ${pattern} === (no match)`)
|
||||
}
|
||||
}
|
||||
|
||||
grep('static list(')
|
||||
grep('sessionFile')
|
||||
grep('get sessionName')
|
||||
grep('setSessionName')
|
||||
grep('static continueRecent')
|
||||
grep('class SessionManager')
|
||||
grep('interface SessionInfo')
|
||||
Reference in New Issue
Block a user