UI iteration 2: toggleable sidebar, floating composer blob, pending-question morph
Sidebar (replaces the modal drawer): - Inline, toggleable, no backdrop/focus theft — can stay open while working - Open state persists across pages (provider in root layout) and reloads (localStorage; starts closed server-side to avoid hydration mismatch, stored value applied post-mount; default open on first run) - Hamburger in page headers only when closed; close button in sidebar header Floating composer blob (per reference screenshot): - Floats over the transcript (absolute overlay), persists at bottom while scrolling, content fades behind it (gradient mask); pb-44 scroll clearance - Concentric corner radii: blob rounded-2xl(16px) − p-1.5(6px) = send rounded-[10px] = 25% of the 40px button; CornerDownLeft icon per reference - Stop moved from header into the blob (busy → square icon) - prompt-input.tsx + 7 orphan ui files pruned (attachments/command palette unused); hand-rolled Composer is ~100 lines and render-tested Pending-question morph (one text field on screen, never two): - Quiz/ask-select pending → blob becomes the note field (send hidden, hint line above); card's internal note input removed; Submit consumes it - Ask free-text pending → blob becomes the answer field; its send submits via the card's registered submit api (ref bridge); Enter works - Chat owns the single text state; card clears it on submit - canSend for answer mode mirrors the card predicate directly — reading the ref during render lagged one commit (found in browser testing) Widths: one shared column (max-w-3xl + px-4) for transcript, cards, and composer — all three measured pixel-identical (left 416, width 736). Assistant Message max-w-full/MessageContent w-full (was max-w-[95%]/w-fit — the 'cards are a bit shorter' bug). User messages render plain text again — no math pipeline on learner-authored text (currency hazard accepted for the model only, guarded by the prompt). Tests: 58 pass (5 new composer pins; card tests updated to controlled props). Verified live: note flow, answer flow, sidebar persistence, float, fade, zero console errors — plus the live model spontaneously honoring the $ currency escape contract in a continuation.
This commit is contained in:
+6
-1
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { AppShell, SidebarProvider } from "@/components/sidebar";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Learning Harness",
|
title: "Learning Harness",
|
||||||
@@ -10,7 +11,11 @@ export const metadata: Metadata = {
|
|||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark">
|
||||||
<body className="min-h-dvh antialiased">{children}</body>
|
<body className="min-h-dvh antialiased">
|
||||||
|
<SidebarProvider>
|
||||||
|
<AppShell>{children}</AppShell>
|
||||||
|
</SidebarProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+145
-112
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
@@ -11,29 +11,28 @@ import {
|
|||||||
} from "@/components/ai-elements/conversation";
|
} from "@/components/ai-elements/conversation";
|
||||||
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
|
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
|
||||||
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
|
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
|
||||||
import {
|
|
||||||
PromptInput,
|
|
||||||
PromptInputBody,
|
|
||||||
PromptInputFooter,
|
|
||||||
PromptInputSubmit,
|
|
||||||
PromptInputTextarea,
|
|
||||||
PromptInputTools,
|
|
||||||
type PromptInputMessage,
|
|
||||||
} from "@/components/ai-elements/prompt-input";
|
|
||||||
import { Shimmer } from "@/components/ai-elements/shimmer";
|
import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { QuizCard } from "@/components/quiz-card";
|
import { QuizCard, type QuizCardSubmitApi } from "@/components/quiz-card";
|
||||||
import { AnsweredCard } from "@/components/answered-card";
|
import { AnsweredCard } from "@/components/answered-card";
|
||||||
import { SessionDrawer } from "@/components/session-drawer";
|
import { Composer, type ComposerMode } from "@/components/composer";
|
||||||
|
import { SidebarToggle } from "@/components/sidebar";
|
||||||
import type { TeacherUIMessage } from "@/src/agent/ui-message";
|
import type { TeacherUIMessage } from "@/src/agent/ui-message";
|
||||||
|
import type { AskToolInput } from "@/src/agent/tools";
|
||||||
import type { QuestionAnswerData } from "@/src/core/question-core";
|
import type { QuestionAnswerData } from "@/src/core/question-core";
|
||||||
import { findPendingToolCall } from "@/src/core/pending";
|
import { findPendingToolCall } from "@/src/core/pending";
|
||||||
|
|
||||||
|
/** One width for everything: transcript, cards, composer all share this column. */
|
||||||
|
const COLUMN = "mx-auto w-full max-w-3xl px-4";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The teaching session. All chat state lives in useChat; the server persists
|
* The teaching session. All chat state lives in useChat; the server persists
|
||||||
* the canonical transcript after every turn. A pending quiz/ask is just an
|
* the canonical transcript after every turn. A pending quiz/ask is just an
|
||||||
* unanswered tool part — so reload/resume mid-question works for free.
|
* unanswered tool part — so reload/resume mid-question works for free.
|
||||||
|
*
|
||||||
|
* The composer morphs while a question is pending (one text field on screen):
|
||||||
|
* select-mode question → the blob is the note field; free-text ask → the blob
|
||||||
|
* is the answer field and its send submits via the card's registered API.
|
||||||
*/
|
*/
|
||||||
export function Chat({
|
export function Chat({
|
||||||
chatId,
|
chatId,
|
||||||
@@ -68,120 +67,154 @@ export function Chat({
|
|||||||
const pending = findPendingToolCall(messages);
|
const pending = findPendingToolCall(messages);
|
||||||
const busy = status === "submitted" || status === "streaming";
|
const busy = status === "submitted" || status === "streaming";
|
||||||
|
|
||||||
const submitAnswer = (toolCallId: string, tool: "quiz" | "ask_user_question", output: QuestionAnswerData) => {
|
// The single free-text field: chat input normally; note/answer while pending.
|
||||||
void addToolOutput({ tool, toolCallId, output });
|
const [text, setText] = useState("");
|
||||||
|
// The pending card registers its submit so the composer can send on Enter.
|
||||||
|
const cardApi = useRef<QuizCardSubmitApi | null>(null);
|
||||||
|
|
||||||
|
// Composer mode: free-text ask → "answer"; any other pending question → "note".
|
||||||
|
const pendingIsTextMode =
|
||||||
|
pending?.tool === "ask_user_question" &&
|
||||||
|
(() => {
|
||||||
|
const ask = pending.input as AskToolInput;
|
||||||
|
return ask.mode === "text" || !ask.options?.length;
|
||||||
|
})();
|
||||||
|
const mode: ComposerMode = pending ? (pendingIsTextMode ? "answer" : "note") : "chat";
|
||||||
|
|
||||||
|
// canSend mirrors the card's own predicate (text-mode: non-empty text).
|
||||||
|
// Reading cardApi.current here would lag one commit behind — the card
|
||||||
|
// re-registers its api in an effect AFTER this render pass.
|
||||||
|
const canSend = mode === "chat" ? text.trim().length > 0 && !pending : text.trim().length > 0;
|
||||||
|
|
||||||
|
const onSend = () => {
|
||||||
|
if (mode === "chat") {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t || busy || pending) return;
|
||||||
|
setText("");
|
||||||
|
void sendMessage({ text: t });
|
||||||
|
} else if (mode === "answer") {
|
||||||
|
cardApi.current?.submit();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onComposerSubmit = (msg: PromptInputMessage) => {
|
const hint =
|
||||||
if (!msg.text.trim() || busy || pending) return;
|
mode === "note"
|
||||||
void sendMessage({ text: msg.text });
|
? "Answering the question above — text here becomes a note on your answer."
|
||||||
};
|
: mode === "answer"
|
||||||
|
? "Type your answer to the question above."
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh flex-col">
|
<div className="flex h-dvh flex-col">
|
||||||
<header className="flex items-center gap-3 border-b px-3 py-2">
|
<header className="flex items-center gap-3 px-3 py-2">
|
||||||
<SessionDrawer />
|
<SidebarToggle />
|
||||||
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
|
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
|
||||||
{busy && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => stop()}>
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Conversation className="flex-1">
|
{/* Transcript scrolls full-height; the composer floats on top. */}
|
||||||
<ConversationContent className="mx-auto w-full max-w-3xl gap-4 px-4 py-6">
|
<div className="relative min-h-0 flex-1">
|
||||||
{messages.length === 0 && !busy && (
|
<Conversation className="h-full">
|
||||||
<ConversationEmptyState
|
<ConversationContent className={`${COLUMN} gap-4 pt-4 pb-44`}>
|
||||||
title="New session"
|
{messages.length === 0 && !busy && (
|
||||||
description="The teacher will open by probing what you already know."
|
<ConversationEmptyState
|
||||||
/>
|
title="New session"
|
||||||
)}
|
description="The teacher will open by probing what you already know."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{messages.map((m, mi) => (
|
{messages.map((m, mi) => (
|
||||||
<Message key={m.id} from={m.role}>
|
<Message key={m.id} from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
|
||||||
<MessageContent>
|
<MessageContent className={m.role === "assistant" ? "w-full" : undefined}>
|
||||||
{m.role === "user" ? (
|
{m.role === "user" ? (
|
||||||
<MessageResponse>
|
// User text renders plain: the model's LaTeX pipeline is not
|
||||||
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
// applied to learner-authored text ($ stays $, no surprises).
|
||||||
</MessageResponse>
|
<div className="whitespace-pre-wrap">
|
||||||
) : (
|
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
||||||
m.parts.map((part, pi) => {
|
</div>
|
||||||
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
|
) : (
|
||||||
switch (part.type) {
|
m.parts.map((part, pi) => {
|
||||||
case "text":
|
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
|
||||||
return <MessageResponse key={pi}>{part.text}</MessageResponse>;
|
switch (part.type) {
|
||||||
case "reasoning":
|
case "text":
|
||||||
return (
|
return <MessageResponse key={pi}>{part.text}</MessageResponse>;
|
||||||
<Reasoning key={pi} isStreaming={status === "streaming" && isLastPart} className="mb-2">
|
case "reasoning":
|
||||||
<ReasoningTrigger />
|
|
||||||
<ReasoningContent>{part.text}</ReasoningContent>
|
|
||||||
</Reasoning>
|
|
||||||
);
|
|
||||||
case "step-start":
|
|
||||||
return pi > 0 ? <Separator key={pi} className="my-3" /> : null;
|
|
||||||
case "tool-quiz":
|
|
||||||
case "tool-ask_user_question": {
|
|
||||||
const tool = part.type === "tool-quiz" ? ("quiz" as const) : ("ask_user_question" as const);
|
|
||||||
if (part.state === "output-available") {
|
|
||||||
return <AnsweredCard key={pi} answer={part.output as QuestionAnswerData} />;
|
|
||||||
}
|
|
||||||
if (part.state === "input-available") {
|
|
||||||
return (
|
return (
|
||||||
<QuizCard
|
<Reasoning key={pi} isStreaming={status === "streaming" && isLastPart} className="mb-2">
|
||||||
key={pi}
|
<ReasoningTrigger />
|
||||||
toolName={tool}
|
<ReasoningContent>{part.text}</ReasoningContent>
|
||||||
toolCallId={part.toolCallId}
|
</Reasoning>
|
||||||
input={part.input}
|
);
|
||||||
onSubmit={(output) => submitAnswer(part.toolCallId, tool, output)}
|
case "step-start":
|
||||||
/>
|
return pi > 0 ? <Separator key={pi} className="my-3" /> : null;
|
||||||
|
case "tool-quiz":
|
||||||
|
case "tool-ask_user_question": {
|
||||||
|
const tool = part.type === "tool-quiz" ? ("quiz" as const) : ("ask_user_question" as const);
|
||||||
|
if (part.state === "output-available") {
|
||||||
|
return <AnsweredCard key={pi} answer={part.output as QuestionAnswerData} />;
|
||||||
|
}
|
||||||
|
if (part.state === "input-available") {
|
||||||
|
const isPending = pending?.toolCallId === part.toolCallId;
|
||||||
|
return (
|
||||||
|
<QuizCard
|
||||||
|
key={pi}
|
||||||
|
toolName={tool}
|
||||||
|
toolCallId={part.toolCallId}
|
||||||
|
input={part.input}
|
||||||
|
pendingText={isPending ? text : ""}
|
||||||
|
onPendingTextChange={isPending ? setText : () => {}}
|
||||||
|
registerSubmit={isPending ? (api) => (cardApi.current = api) : undefined}
|
||||||
|
onSubmit={(output) => {
|
||||||
|
setText("");
|
||||||
|
void addToolOutput({ tool, toolCallId: part.toolCallId, output });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// input-streaming: the question is still being generated
|
||||||
|
return (
|
||||||
|
<div key={pi} className="rounded-xl border bg-card p-4">
|
||||||
|
<Shimmer className="text-sm">Preparing a question…</Shimmer>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// input-streaming: the question is still being generated
|
default:
|
||||||
return (
|
return null;
|
||||||
<div key={pi} className="rounded-xl border bg-card p-4">
|
|
||||||
<Shimmer className="text-sm">Preparing a question…</Shimmer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
default:
|
})
|
||||||
return null;
|
)}
|
||||||
}
|
</MessageContent>
|
||||||
})
|
</Message>
|
||||||
)}
|
))}
|
||||||
</MessageContent>
|
|
||||||
</Message>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{status === "submitted" && <Shimmer className="text-sm">Thinking…</Shimmer>}
|
{status === "submitted" && <Shimmer className="text-sm">Thinking…</Shimmer>}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
||||||
Something went wrong: {error.message}
|
Something went wrong: {error.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ConversationContent>
|
</ConversationContent>
|
||||||
<ConversationScrollButton />
|
<ConversationScrollButton />
|
||||||
</Conversation>
|
</Conversation>
|
||||||
|
|
||||||
<div className="border-t px-4 py-3">
|
{/* Floating composer: content fades out behind it. */}
|
||||||
<div className="mx-auto w-full max-w-3xl">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0">
|
||||||
{pending && (
|
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
|
||||||
<div className="mb-2 text-center text-xs text-muted-foreground">
|
<div className="bg-background pb-4">
|
||||||
Answer the question above to continue.
|
<div className={COLUMN}>
|
||||||
|
{hint && <div className="mb-1.5 text-center text-xs text-muted-foreground">{hint}</div>}
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<Composer
|
||||||
|
mode={mode}
|
||||||
|
busy={busy}
|
||||||
|
value={text}
|
||||||
|
onChange={setText}
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={() => stop()}
|
||||||
|
canSend={canSend}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<PromptInput onSubmit={onComposerSubmit}>
|
|
||||||
<PromptInputBody>
|
|
||||||
<PromptInputTextarea
|
|
||||||
placeholder={pending ? "Answer the question above first…" : "Ask, answer, or steer the lesson…"}
|
|
||||||
disabled={!!pending}
|
|
||||||
/>
|
|
||||||
</PromptInputBody>
|
|
||||||
<PromptInputFooter>
|
|
||||||
<PromptInputTools />
|
|
||||||
<PromptInputSubmit status={status} disabled={busy || !!pending} />
|
|
||||||
</PromptInputFooter>
|
|
||||||
</PromptInput>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CornerDownLeftIcon, SquareIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The floating composer blob. Sits on top of the transcript (the parent
|
||||||
|
* overlays it), persists at the bottom while scrolling, content fades out
|
||||||
|
* behind it.
|
||||||
|
*
|
||||||
|
* Corner-radius math (concentric rounding — outer = inner + gap):
|
||||||
|
* blob rounded-2xl (16px) − padding p-1.5 (6px) = button rounded-[10px].
|
||||||
|
* 10px is 25% of the 40px send button — matches the reference.
|
||||||
|
*
|
||||||
|
* Modes (one text field on screen, always):
|
||||||
|
* chat — normal message composer.
|
||||||
|
* note — a question is pending; text here becomes the answer's note.
|
||||||
|
* No send action (the card's Submit sends); Enter = newline.
|
||||||
|
* answer — a free-text ask is pending; this field IS the answer and the
|
||||||
|
* send button submits it.
|
||||||
|
*/
|
||||||
|
export type ComposerMode = "chat" | "note" | "answer";
|
||||||
|
|
||||||
|
const PLACEHOLDERS: Record<ComposerMode, string> = {
|
||||||
|
chat: "Ask, answer, or steer the lesson…",
|
||||||
|
note: "Add a note (optional) — anything you're unsure about",
|
||||||
|
answer: "Type your answer…",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Composer({
|
||||||
|
mode,
|
||||||
|
busy,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSend,
|
||||||
|
onStop,
|
||||||
|
canSend,
|
||||||
|
autoFocus,
|
||||||
|
}: {
|
||||||
|
mode: ComposerMode;
|
||||||
|
busy: boolean;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
onSend: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
canSend: boolean;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}) {
|
||||||
|
const showSend = mode !== "note";
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
// Enter sends (chat/answer); Shift+Enter newline. Note mode: no send.
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && mode !== "note") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) onStop();
|
||||||
|
else if (canSend) onSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
data-testid="composer"
|
||||||
|
data-mode={mode}
|
||||||
|
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) onStop();
|
||||||
|
else if (canSend && showSend) onSend();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={PLACEHOLDERS[mode]}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
rows={1}
|
||||||
|
className="field-sizing-content max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
{showSend && (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label={busy ? "Stop" : "Send"}
|
||||||
|
className={cn(
|
||||||
|
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
|
||||||
|
busy
|
||||||
|
? "bg-secondary text-foreground hover:bg-accent"
|
||||||
|
: "bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40",
|
||||||
|
)}
|
||||||
|
disabled={!busy && !canSend}
|
||||||
|
>
|
||||||
|
{busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
+29
-17
@@ -2,15 +2,13 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { WaypointsIcon } from "lucide-react";
|
import { CornerDownLeftIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { SidebarToggle } from "@/components/sidebar";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { SessionDrawer } from "@/components/session-drawer";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home — the knowledge graph is the main view (placeholder until the graph
|
* Home — the knowledge graph is the main view (placeholder until the graph
|
||||||
* pipeline lands), with the new-session input docked below it. Sessions live
|
* pipeline lands), with the new-session blob docked below it. Sessions live
|
||||||
* in the hamburger drawer.
|
* in the toggleable sidebar.
|
||||||
*/
|
*/
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -37,7 +35,7 @@ export function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-dvh flex-col">
|
<div className="flex h-dvh flex-col">
|
||||||
<header className="flex items-center gap-2 px-3 py-2">
|
<header className="flex items-center gap-2 px-3 py-2">
|
||||||
<SessionDrawer />
|
<SidebarToggle />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Graph — the main view. Placeholder until the knowledge graph lands. */}
|
{/* Graph — the main view. Placeholder until the knowledge graph lands. */}
|
||||||
@@ -52,26 +50,40 @@ export function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* New session input */}
|
{/* New session — same floating blob as the chat composer. */}
|
||||||
<div className="px-4 pb-6">
|
<div className="pb-4">
|
||||||
<form
|
<div className="mx-auto w-full max-w-3xl px-4">
|
||||||
className="mx-auto flex w-full max-w-2xl gap-2"
|
<form
|
||||||
|
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void start();
|
void start();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<textarea
|
||||||
value={topic}
|
value={topic}
|
||||||
onChange={(e) => setTopic(e.target.value)}
|
onChange={(e) => setTopic(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
void start();
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="What do you want to learn?"
|
placeholder="What do you want to learn?"
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-11"
|
rows={1}
|
||||||
|
className="field-sizing-content max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={!topic.trim() || busy} className="h-11">
|
<button
|
||||||
{busy ? "Starting…" : "Start"}
|
type="submit"
|
||||||
</Button>
|
aria-label="Start session"
|
||||||
</form>
|
disabled={!topic.trim() || busy}
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-primary text-primary-foreground transition-colors hover:opacity-90 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<CornerDownLeftIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+32
-18
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -26,16 +26,29 @@ import {
|
|||||||
*
|
*
|
||||||
* Quiz: graded (single/multi-select + "I don't know").
|
* Quiz: graded (single/multi-select + "I don't know").
|
||||||
* Ask: not graded (single/multi-select + "Other" free text, or text mode).
|
* Ask: not graded (single/multi-select + "Other" free text, or text mode).
|
||||||
|
*
|
||||||
|
* The card owns selection state; the free-text field lives in the composer
|
||||||
|
* blob (one text input on screen, never two). `pendingText` is the controlled
|
||||||
|
* text: the note for select modes, the answer for text mode. `registerSubmit`
|
||||||
|
* lets the composer trigger submit (Enter in answer mode).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface QuizCardSubmitApi {
|
||||||
|
submit: () => void;
|
||||||
|
canSubmit: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QuizCardProps {
|
export interface QuizCardProps {
|
||||||
toolName: "quiz" | "ask_user_question";
|
toolName: "quiz" | "ask_user_question";
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
input: QuizToolInput | AskToolInput;
|
input: QuizToolInput | AskToolInput;
|
||||||
onSubmit: (output: QuestionAnswerData) => void;
|
onSubmit: (output: QuestionAnswerData) => void;
|
||||||
|
pendingText: string;
|
||||||
|
onPendingTextChange: (v: string) => void;
|
||||||
|
registerSubmit?: (api: QuizCardSubmitApi | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProps) {
|
export function QuizCard({ toolName, toolCallId, input, onSubmit, pendingText, onPendingTextChange, registerSubmit }: QuizCardProps) {
|
||||||
const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const);
|
const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const);
|
||||||
|
|
||||||
// Build the canonical QuestionData once per question. Quiz options are
|
// Build the canonical QuestionData once per question. Quiz options are
|
||||||
@@ -76,8 +89,9 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp
|
|||||||
const [dontKnow, setDontKnow] = useState(false);
|
const [dontKnow, setDontKnow] = useState(false);
|
||||||
const [showOther, setShowOther] = useState(false);
|
const [showOther, setShowOther] = useState(false);
|
||||||
const [other, setOther] = useState("");
|
const [other, setOther] = useState("");
|
||||||
const [text, setText] = useState("");
|
// Free text lives in the composer blob: note (select modes) or answer (text mode).
|
||||||
const [note, setNote] = useState("");
|
const note = pendingText;
|
||||||
|
const text = pendingText;
|
||||||
|
|
||||||
const toggle = (idx: number) => {
|
const toggle = (idx: number) => {
|
||||||
setDontKnow(false);
|
setDontKnow(false);
|
||||||
@@ -117,11 +131,19 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp
|
|||||||
values: [...selected].map((i) => qd.options[i]!.value),
|
values: [...selected].map((i) => qd.options[i]!.value),
|
||||||
other: showOther ? other : undefined,
|
other: showOther ? other : undefined,
|
||||||
text: qd.mode === "text" ? text : undefined,
|
text: qd.mode === "text" ? text : undefined,
|
||||||
note: note.trim() || undefined,
|
note: qd.mode !== "text" ? note.trim() || undefined : undefined,
|
||||||
};
|
};
|
||||||
|
onPendingTextChange("");
|
||||||
onSubmit(buildAnswer(qd, req));
|
onSubmit(buildAnswer(qd, req));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Let the composer blob submit on Enter (answer mode).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!registerSubmit) return;
|
||||||
|
registerSubmit({ submit, canSubmit: () => canSubmit });
|
||||||
|
return () => registerSubmit(null);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="quiz-card">
|
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="quiz-card">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
@@ -200,24 +222,16 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(qd.mode === "text" || showOther) && (
|
{showOther && (
|
||||||
<textarea
|
<textarea
|
||||||
className="mt-2 w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
className="mt-2 w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||||
rows={qd.mode === "text" ? 4 : 2}
|
rows={2}
|
||||||
placeholder={qd.mode === "text" ? "Type your answer…" : "Type your own answer…"}
|
placeholder="Type your own answer…"
|
||||||
value={qd.mode === "text" ? text : other}
|
value={other}
|
||||||
onChange={(e) => (qd.mode === "text" ? setText(e.target.value) : setOther(e.target.value))}
|
onChange={(e) => setOther(e.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="mt-2 w-full rounded-lg border bg-background px-3 py-1.5 text-sm outline-none focus:ring-1 focus:ring-ring"
|
|
||||||
placeholder="Add a note (optional) — anything you're unsure about"
|
|
||||||
value={note}
|
|
||||||
onChange={(e) => setNote(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
<Button size="sm" onClick={submit} disabled={!canSubmit}>
|
<Button size="sm" onClick={submit} disabled={!canSubmit}>
|
||||||
Submit answer
|
Submit answer
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { MenuIcon, XIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { ChatSummary } from "@/src/store/chats";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's primary navigation: a hamburger that opens the session list.
|
|
||||||
* Sessions only — settings and other chrome will dock here later.
|
|
||||||
* Renders the toggle button (sits in the page header) plus the slide-over.
|
|
||||||
*/
|
|
||||||
export function SessionDrawer() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [chats, setChats] = useState<ChatSummary[] | null>(null);
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/chats", { cache: "no-store" });
|
|
||||||
const data = (await res.json()) as { chats: ChatSummary[] };
|
|
||||||
setChats(data.chats);
|
|
||||||
} catch {
|
|
||||||
setChats([]);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) void load();
|
|
||||||
}, [open, load]);
|
|
||||||
|
|
||||||
// ESC closes; body scroll locks while open.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const go = (href: string) => {
|
|
||||||
setOpen(false);
|
|
||||||
router.push(href);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Open sessions" onClick={() => setOpen(true)}>
|
|
||||||
<MenuIcon className="size-5" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* backdrop */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-40 bg-black/50 transition-opacity",
|
|
||||||
open ? "opacity-100" : "pointer-events-none opacity-0",
|
|
||||||
)}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* panel */}
|
|
||||||
<aside
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-y-0 left-0 z-50 flex w-72 flex-col border-r bg-background transition-transform duration-200",
|
|
||||||
open ? "translate-x-0" : "-translate-x-full",
|
|
||||||
)}
|
|
||||||
aria-hidden={!open}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => go("/")}
|
|
||||||
className="font-semibold tracking-tight hover:opacity-80"
|
|
||||||
>
|
|
||||||
Learning Harness
|
|
||||||
</button>
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Close sessions" onClick={() => setOpen(false)}>
|
|
||||||
<XIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
|
||||||
{chats === null ? (
|
|
||||||
<div className="px-2 py-4 text-sm text-muted-foreground">Loading…</div>
|
|
||||||
) : chats.length === 0 ? (
|
|
||||||
<div className="px-2 py-4 text-sm text-muted-foreground">No sessions yet.</div>
|
|
||||||
) : (
|
|
||||||
chats.map((c) => {
|
|
||||||
const active = pathname === `/chat/${c.id}`;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={c.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => go(`/chat/${c.id}`)}
|
|
||||||
className={cn(
|
|
||||||
"mb-0.5 w-full rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent",
|
|
||||||
active && "bg-accent font-medium",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="truncate">{c.title}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{c.messageCount} messages · {new Date(c.updatedAt).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ChatSummary } from "@/src/store/chats";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It
|
||||||
|
* sits inline next to the content and can stay open while working in a
|
||||||
|
* session. Open state persists across pages (provider lives in the root
|
||||||
|
* layout) and across reloads (localStorage).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "lh:sidebar-open";
|
||||||
|
|
||||||
|
const SidebarContext = createContext<{ open: boolean; toggle: () => void }>({
|
||||||
|
open: false,
|
||||||
|
toggle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useSidebar() {
|
||||||
|
return useContext(SidebarContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
// Start closed so server and client first-render agree (no hydration
|
||||||
|
// mismatch); the stored value is applied after mount.
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
// Default: open (first run introduces the sidebar).
|
||||||
|
setOpen(stored === null ? true : stored === "1");
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setOpen((v) => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, v ? "0" : "1");
|
||||||
|
return !v;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={{ open, toggle }}>
|
||||||
|
{/* transitions gated on mount: no slide-in flash on page load */}
|
||||||
|
<div className={cn("flex h-dvh", mounted && "[&_.sidebar-motion]:transition-[width]")}>{children}</div>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hamburger for page headers — only visible when the sidebar is closed. */
|
||||||
|
export function SidebarToggle() {
|
||||||
|
const { open, toggle } = useSidebar();
|
||||||
|
if (open) return null;
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Open sidebar" onClick={toggle}>
|
||||||
|
<PanelLeftOpenIcon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionsSidebar() {
|
||||||
|
const { open, toggle } = useSidebar();
|
||||||
|
const [chats, setChats] = useState<ChatSummary[] | null>(null);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetch("/api/chats", { cache: "no-store" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d: { chats: ChatSummary[] }) => !cancelled && setChats(d.chats))
|
||||||
|
.catch(() => !cancelled && setChats([]));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn("sidebar-motion shrink-0 overflow-hidden duration-200", open ? "w-72" : "w-0")}
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
|
<div className="flex h-full w-72 flex-col border-r bg-background">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
|
<Link href="/" className="font-semibold tracking-tight hover:opacity-80">
|
||||||
|
Learning Harness
|
||||||
|
</Link>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Close sidebar" onClick={toggle}>
|
||||||
|
<PanelLeftCloseIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{chats === null ? (
|
||||||
|
<div className="px-2 py-4 text-sm text-muted-foreground">Loading…</div>
|
||||||
|
) : chats.length === 0 ? (
|
||||||
|
<div className="px-2 py-4 text-sm text-muted-foreground">No sessions yet.</div>
|
||||||
|
) : (
|
||||||
|
chats.map((c) => {
|
||||||
|
const active = pathname === `/chat/${c.id}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/chat/${c.id}`)}
|
||||||
|
className={cn(
|
||||||
|
"mb-0.5 w-full rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent",
|
||||||
|
active && "bg-accent font-medium",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="truncate">{c.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{c.messageCount} messages · {new Date(c.updatedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app frame: sidebar inline + content. Rendered once in the root layout. */
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SessionsSidebar />
|
||||||
|
<main className="min-w-0 flex-1">{children}</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
|
||||||
import { SearchIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
|
|
||||||
function Command({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive
|
|
||||||
data-slot="command"
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandDialog({
|
|
||||||
title = "Command Palette",
|
|
||||||
description = "Search for a command to run...",
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
showCloseButton = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
className?: string
|
|
||||||
showCloseButton?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Dialog {...props}>
|
|
||||||
<DialogHeader className="sr-only">
|
|
||||||
<DialogTitle>{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogContent
|
|
||||||
className={cn("overflow-hidden p-0", className)}
|
|
||||||
showCloseButton={showCloseButton}
|
|
||||||
>
|
|
||||||
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
|
||||||
{children}
|
|
||||||
</Command>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandInput({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="command-input-wrapper"
|
|
||||||
className="flex h-9 items-center gap-2 border-b px-3"
|
|
||||||
>
|
|
||||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
|
||||||
<CommandPrimitive.Input
|
|
||||||
data-slot="command-input"
|
|
||||||
className={cn(
|
|
||||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandList({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive.List
|
|
||||||
data-slot="command-list"
|
|
||||||
className={cn(
|
|
||||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandEmpty({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive.Empty
|
|
||||||
data-slot="command-empty"
|
|
||||||
className="py-6 text-center text-sm"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandGroup({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive.Group
|
|
||||||
data-slot="command-group"
|
|
||||||
className={cn(
|
|
||||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive.Separator
|
|
||||||
data-slot="command-separator"
|
|
||||||
className={cn("-mx-1 h-px bg-border", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandItem({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive.Item
|
|
||||||
data-slot="command-item"
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandShortcut({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="command-shortcut"
|
|
||||||
className={cn(
|
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Command,
|
|
||||||
CommandDialog,
|
|
||||||
CommandInput,
|
|
||||||
CommandList,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandItem,
|
|
||||||
CommandShortcut,
|
|
||||||
CommandSeparator,
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { XIcon } from "lucide-react"
|
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
function Dialog({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogClose({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogOverlay({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Overlay
|
|
||||||
data-slot="dialog-overlay"
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
showCloseButton = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
||||||
showCloseButton?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DialogPortal data-slot="dialog-portal">
|
|
||||||
<DialogOverlay />
|
|
||||||
<DialogPrimitive.Content
|
|
||||||
data-slot="dialog-content"
|
|
||||||
className={cn(
|
|
||||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{showCloseButton && (
|
|
||||||
<DialogPrimitive.Close
|
|
||||||
data-slot="dialog-close"
|
|
||||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
)}
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPortal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="dialog-header"
|
|
||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogFooter({
|
|
||||||
className,
|
|
||||||
showCloseButton = false,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
showCloseButton?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="dialog-footer"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{showCloseButton && (
|
|
||||||
<DialogPrimitive.Close asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Title
|
|
||||||
data-slot="dialog-title"
|
|
||||||
className={cn("text-lg leading-none font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Description
|
|
||||||
data-slot="dialog-description"
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function DropdownMenu({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Trigger
|
|
||||||
data-slot="dropdown-menu-trigger"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuContent({
|
|
||||||
className,
|
|
||||||
sideOffset = 4,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal>
|
|
||||||
<DropdownMenuPrimitive.Content
|
|
||||||
data-slot="dropdown-menu-content"
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuItem({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
variant?: "default" | "destructive"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Item
|
|
||||||
data-slot="dropdown-menu-item"
|
|
||||||
data-inset={inset}
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
checked,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
checked={checked}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.RadioGroup
|
|
||||||
data-slot="dropdown-menu-radio-group"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.RadioItem
|
|
||||||
data-slot="dropdown-menu-radio-item"
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<CircleIcon className="size-2 fill-current" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Label
|
|
||||||
data-slot="dropdown-menu-label"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Separator
|
|
||||||
data-slot="dropdown-menu-separator"
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="dropdown-menu-shortcut"
|
|
||||||
className={cn(
|
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSub({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
|
||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubContent
|
|
||||||
data-slot="dropdown-menu-sub-content"
|
|
||||||
className={cn(
|
|
||||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { HoverCard as HoverCardPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function HoverCard({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
|
||||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function HoverCardTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function HoverCardContent({
|
|
||||||
className,
|
|
||||||
align = "center",
|
|
||||||
sideOffset = 4,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
|
||||||
<HoverCardPrimitive.Content
|
|
||||||
data-slot="hover-card-content"
|
|
||||||
align={align}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</HoverCardPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
|
|
||||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="input-group"
|
|
||||||
role="group"
|
|
||||||
className={cn(
|
|
||||||
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30",
|
|
||||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
|
||||||
|
|
||||||
// Variants based on alignment.
|
|
||||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
|
||||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
|
||||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
|
||||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
|
||||||
|
|
||||||
// Focus state.
|
|
||||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
|
|
||||||
|
|
||||||
// Error state.
|
|
||||||
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
|
||||||
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputGroupAddonVariants = cva(
|
|
||||||
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
align: {
|
|
||||||
"inline-start":
|
|
||||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
|
||||||
"inline-end":
|
|
||||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
|
||||||
"block-start":
|
|
||||||
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
|
|
||||||
"block-end":
|
|
||||||
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
align: "inline-start",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function InputGroupAddon({
|
|
||||||
className,
|
|
||||||
align = "inline-start",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
data-slot="input-group-addon"
|
|
||||||
data-align={align}
|
|
||||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("button")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputGroupButtonVariants = cva(
|
|
||||||
"flex items-center gap-2 text-sm shadow-none",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
|
||||||
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
|
|
||||||
"icon-xs":
|
|
||||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
|
||||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
size: "xs",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function InputGroupButton({
|
|
||||||
className,
|
|
||||||
type = "button",
|
|
||||||
variant = "ghost",
|
|
||||||
size = "xs",
|
|
||||||
...props
|
|
||||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
|
||||||
VariantProps<typeof inputGroupButtonVariants>) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type={type}
|
|
||||||
data-size={size}
|
|
||||||
variant={variant}
|
|
||||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputGroupInput({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"input">) {
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
data-slot="input-group-control"
|
|
||||||
className={cn(
|
|
||||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputGroupTextarea({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"textarea">) {
|
|
||||||
return (
|
|
||||||
<Textarea
|
|
||||||
data-slot="input-group-control"
|
|
||||||
className={cn(
|
|
||||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
InputGroup,
|
|
||||||
InputGroupAddon,
|
|
||||||
InputGroupButton,
|
|
||||||
InputGroupText,
|
|
||||||
InputGroupInput,
|
|
||||||
InputGroupTextarea,
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
|
||||||
import { Select as SelectPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Select({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectValue({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectTrigger({
|
|
||||||
className,
|
|
||||||
size = "default",
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
|
||||||
size?: "sm" | "default"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Trigger
|
|
||||||
data-slot="select-trigger"
|
|
||||||
data-size={size}
|
|
||||||
className={cn(
|
|
||||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SelectPrimitive.Icon asChild>
|
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
|
||||||
</SelectPrimitive.Icon>
|
|
||||||
</SelectPrimitive.Trigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
position = "item-aligned",
|
|
||||||
align = "center",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Portal>
|
|
||||||
<SelectPrimitive.Content
|
|
||||||
data-slot="select-content"
|
|
||||||
className={cn(
|
|
||||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
||||||
position === "popper" &&
|
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
position={position}
|
|
||||||
align={align}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SelectScrollUpButton />
|
|
||||||
<SelectPrimitive.Viewport
|
|
||||||
className={cn(
|
|
||||||
"p-1",
|
|
||||||
position === "popper" &&
|
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Viewport>
|
|
||||||
<SelectScrollDownButton />
|
|
||||||
</SelectPrimitive.Content>
|
|
||||||
</SelectPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectLabel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
data-slot="select-label"
|
|
||||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
data-slot="select-item"
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
data-slot="select-item-indicator"
|
|
||||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
|
||||||
>
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
</SelectPrimitive.Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
data-slot="select-separator"
|
|
||||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollUpButton
|
|
||||||
data-slot="select-scroll-up-button"
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronUpIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ScrollUpButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollDownButton
|
|
||||||
data-slot="select-scroll-down-button"
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronDownIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ScrollDownButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectScrollDownButton,
|
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Loader2Icon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
|
||||||
return (
|
|
||||||
<Loader2Icon
|
|
||||||
role="status"
|
|
||||||
aria-label="Loading"
|
|
||||||
className={cn("size-4 animate-spin", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Spinner }
|
|
||||||
+3
-1
@@ -8,6 +8,8 @@ import { isToolUIPart, type UIMessage } from "ai";
|
|||||||
export interface PendingToolCall {
|
export interface PendingToolCall {
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
tool: string;
|
tool: string;
|
||||||
|
/** Tool input (quiz/ask args) — lets the composer pick note vs answer mode. */
|
||||||
|
input: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null {
|
export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null {
|
||||||
@@ -16,7 +18,7 @@ export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | nu
|
|||||||
for (let p = msg.parts.length - 1; p >= 0; p--) {
|
for (let p = msg.parts.length - 1; p >= 0; p--) {
|
||||||
const part = msg.parts[p]!;
|
const part = msg.parts[p]!;
|
||||||
if (isToolUIPart(part) && part.state === "input-available") {
|
if (isToolUIPart(part) && part.state === "input-available") {
|
||||||
return { toolCallId: part.toolCallId, tool: part.type.slice("tool-".length) };
|
return { toolCallId: part.toolCallId, tool: part.type.slice("tool-".length), input: part.input };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,9 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
|
|
||||||
// The pending-question gate sees it.
|
// The pending-question gate sees it.
|
||||||
const pending = findPendingToolCall(saved1.messages);
|
const pending = findPendingToolCall(saved1.messages);
|
||||||
assert.deepEqual(pending, { toolCallId: "call-1", tool: "quiz" });
|
assert.equal(pending?.toolCallId, "call-1");
|
||||||
|
assert.equal(pending?.tool, "quiz");
|
||||||
|
assert.deepEqual(pending?.input, QUIZ_INPUT);
|
||||||
|
|
||||||
// ── The learner answers: client grades, output goes back on the part ──
|
// ── The learner answers: client grades, output goes back on the part ──
|
||||||
const options = normalizeOptions(QUIZ_INPUT.options);
|
const options = normalizeOptions(QUIZ_INPUT.options);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function quizQD(): QuestionData {
|
|||||||
describe("render smoke tests", () => {
|
describe("render smoke tests", () => {
|
||||||
it("QuizCard renders question, options, and the automatic 'I don't know'", () => {
|
it("QuizCard renders question, options, and the automatic 'I don't know'", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<QuizCard toolName="quiz" toolCallId="q1" input={quizInput} onSubmit={() => {}} />,
|
<QuizCard toolName="quiz" toolCallId="q1" input={quizInput} onSubmit={() => {}} pendingText="" onPendingTextChange={() => {}} />,
|
||||||
);
|
);
|
||||||
assert.match(html, /Which planet is closest to the sun\?/);
|
assert.match(html, /Which planet is closest to the sun\?/);
|
||||||
assert.match(html, /Mercury/);
|
assert.match(html, /Mercury/);
|
||||||
@@ -56,22 +56,25 @@ describe("render smoke tests", () => {
|
|||||||
|
|
||||||
it("QuizCard never leaks the explanation or the correct answer before submit", () => {
|
it("QuizCard never leaks the explanation or the correct answer before submit", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<QuizCard toolName="quiz" toolCallId="q1" input={quizInput} onSubmit={() => {}} />,
|
<QuizCard toolName="quiz" toolCallId="q1" input={quizInput} onSubmit={() => {}} pendingText="" onPendingTextChange={() => {}} />,
|
||||||
);
|
);
|
||||||
assert.ok(!html.includes("innermost"), "explanation must not render pre-answer");
|
assert.ok(!html.includes("innermost"), "explanation must not render pre-answer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ask card renders Other + note and no grading affordance", () => {
|
it("ask card renders Other, no note field (note lives in the composer), no grading", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<QuizCard
|
<QuizCard
|
||||||
toolName="ask_user_question"
|
toolName="ask_user_question"
|
||||||
toolCallId="a1"
|
toolCallId="a1"
|
||||||
input={{ question: "How confident are you?", options: [{ label: "Very" }, { label: "Somewhat" }] }}
|
input={{ question: "How confident are you?", options: [{ label: "Very" }, { label: "Somewhat" }] }}
|
||||||
onSubmit={() => {}}
|
onSubmit={() => {}}
|
||||||
|
pendingText=""
|
||||||
|
onPendingTextChange={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
assert.match(html, /Other/);
|
assert.match(html, /Other/);
|
||||||
assert.ok(!html.includes("I don't know"), "ask has no 'I don't know'");
|
assert.ok(!html.includes("I don't know"), "ask has no 'I don't know'");
|
||||||
|
assert.ok(!html.includes("Add a note"), "note field moved to the composer blob");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("AnsweredCard grades quiz answers (✓/✗ + explanation)", () => {
|
it("AnsweredCard grades quiz answers (✓/✗ + explanation)", () => {
|
||||||
@@ -141,7 +144,7 @@ describe("math rendering (streamdown + KaTeX)", () => {
|
|||||||
explanation: "The power rule gives $\\frac{dy}{dx} = 2x$.",
|
explanation: "The power rule gives $\\frac{dy}{dx} = 2x$.",
|
||||||
};
|
};
|
||||||
const pendingHtml = renderToStaticMarkup(
|
const pendingHtml = renderToStaticMarkup(
|
||||||
<QuizCard toolName="quiz" toolCallId="m1" input={mathQuiz} onSubmit={() => {}} />,
|
<QuizCard toolName="quiz" toolCallId="m1" input={mathQuiz} onSubmit={() => {}} pendingText="" onPendingTextChange={() => {}} />,
|
||||||
);
|
);
|
||||||
assert.ok(pendingHtml.includes("katex"), "pending card: question/options math renders");
|
assert.ok(pendingHtml.includes("katex"), "pending card: question/options math renders");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Composer blob render tests — the morphing text field (chat / note / answer
|
||||||
|
* modes), concentric corner radii, and the stop-while-busy button.
|
||||||
|
*
|
||||||
|
* Same tsx classic-JSX shim as render-cards.test.tsx.
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { Composer } from "../components/composer.tsx";
|
||||||
|
|
||||||
|
function render(mode: "chat" | "note" | "answer", opts: { busy?: boolean; canSend?: boolean } = {}) {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
<Composer
|
||||||
|
mode={mode}
|
||||||
|
busy={opts.busy ?? false}
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
onSend={() => {}}
|
||||||
|
onStop={() => {}}
|
||||||
|
canSend={opts.canSend ?? true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Composer blob", () => {
|
||||||
|
it("chat mode: chat placeholder + send button", () => {
|
||||||
|
const html = render("chat");
|
||||||
|
assert.match(html, /Ask, answer, or steer the lesson/);
|
||||||
|
assert.ok(html.includes('aria-label="Send"'), "send button present");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("note mode: note placeholder and NO send button (card submits)", () => {
|
||||||
|
const html = render("note");
|
||||||
|
assert.match(html, /Add a note/);
|
||||||
|
assert.ok(!html.includes('aria-label="Send"'), "no send in note mode");
|
||||||
|
assert.ok(!html.includes('aria-label="Stop"'), "no stop either");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answer mode: answer placeholder + send button (submits the answer)", () => {
|
||||||
|
const html = render("answer");
|
||||||
|
assert.match(html, /Type your answer/);
|
||||||
|
assert.ok(html.includes('aria-label="Send"'), "send present in answer mode");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("busy: send button becomes stop", () => {
|
||||||
|
const html = render("chat", { busy: true });
|
||||||
|
assert.ok(html.includes('aria-label="Stop"'), "stop replaces send while streaming");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("concentric corner radii: blob 16px, send 10px (25% of the 40px button)", () => {
|
||||||
|
const html = render("chat");
|
||||||
|
assert.ok(html.includes("rounded-2xl"), "blob radius");
|
||||||
|
assert.ok(html.includes("rounded-[10px]"), "send radius = blob radius − padding");
|
||||||
|
assert.ok(html.includes("size-10"), "40px send button (10px = 25%)");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user