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 "katex/dist/katex.min.css";
|
||||
import "./globals.css";
|
||||
import { AppShell, SidebarProvider } from "@/components/sidebar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Learning Harness",
|
||||
@@ -10,7 +11,11 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+145
-112
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
@@ -11,29 +11,28 @@ import {
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
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 { 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 { AskToolInput } from "@/src/agent/tools";
|
||||
import type { QuestionAnswerData } from "@/src/core/question-core";
|
||||
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 canonical transcript after every turn. A pending quiz/ask is just an
|
||||
* 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({
|
||||
chatId,
|
||||
@@ -68,120 +67,154 @@ export function Chat({
|
||||
const pending = findPendingToolCall(messages);
|
||||
const busy = status === "submitted" || status === "streaming";
|
||||
|
||||
const submitAnswer = (toolCallId: string, tool: "quiz" | "ask_user_question", output: QuestionAnswerData) => {
|
||||
void addToolOutput({ tool, toolCallId, output });
|
||||
// The single free-text field: chat input normally; note/answer while pending.
|
||||
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) => {
|
||||
if (!msg.text.trim() || busy || pending) return;
|
||||
void sendMessage({ text: msg.text });
|
||||
};
|
||||
const hint =
|
||||
mode === "note"
|
||||
? "Answering the question above — text here becomes a note on your answer."
|
||||
: mode === "answer"
|
||||
? "Type your answer to the question above."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col">
|
||||
<header className="flex items-center gap-3 border-b px-3 py-2">
|
||||
<SessionDrawer />
|
||||
<header className="flex items-center gap-3 px-3 py-2">
|
||||
<SidebarToggle />
|
||||
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
|
||||
{busy && (
|
||||
<Button variant="outline" size="sm" onClick={() => stop()}>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent className="mx-auto w-full max-w-3xl gap-4 px-4 py-6">
|
||||
{messages.length === 0 && !busy && (
|
||||
<ConversationEmptyState
|
||||
title="New session"
|
||||
description="The teacher will open by probing what you already know."
|
||||
/>
|
||||
)}
|
||||
{/* Transcript scrolls full-height; the composer floats on top. */}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<Conversation className="h-full">
|
||||
<ConversationContent className={`${COLUMN} gap-4 pt-4 pb-44`}>
|
||||
{messages.length === 0 && !busy && (
|
||||
<ConversationEmptyState
|
||||
title="New session"
|
||||
description="The teacher will open by probing what you already know."
|
||||
/>
|
||||
)}
|
||||
|
||||
{messages.map((m, mi) => (
|
||||
<Message key={m.id} from={m.role}>
|
||||
<MessageContent>
|
||||
{m.role === "user" ? (
|
||||
<MessageResponse>
|
||||
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
||||
</MessageResponse>
|
||||
) : (
|
||||
m.parts.map((part, pi) => {
|
||||
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return <MessageResponse key={pi}>{part.text}</MessageResponse>;
|
||||
case "reasoning":
|
||||
return (
|
||||
<Reasoning key={pi} isStreaming={status === "streaming" && isLastPart} className="mb-2">
|
||||
<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") {
|
||||
{messages.map((m, mi) => (
|
||||
<Message key={m.id} from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
|
||||
<MessageContent className={m.role === "assistant" ? "w-full" : undefined}>
|
||||
{m.role === "user" ? (
|
||||
// User text renders plain: the model's LaTeX pipeline is not
|
||||
// applied to learner-authored text ($ stays $, no surprises).
|
||||
<div className="whitespace-pre-wrap">
|
||||
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
||||
</div>
|
||||
) : (
|
||||
m.parts.map((part, pi) => {
|
||||
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return <MessageResponse key={pi}>{part.text}</MessageResponse>;
|
||||
case "reasoning":
|
||||
return (
|
||||
<QuizCard
|
||||
key={pi}
|
||||
toolName={tool}
|
||||
toolCallId={part.toolCallId}
|
||||
input={part.input}
|
||||
onSubmit={(output) => submitAnswer(part.toolCallId, tool, output)}
|
||||
/>
|
||||
<Reasoning key={pi} isStreaming={status === "streaming" && isLastPart} className="mb-2">
|
||||
<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") {
|
||||
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
|
||||
return (
|
||||
<div key={pi} className="rounded-xl border bg-card p-4">
|
||||
<Shimmer className="text-sm">Preparing a question…</Shimmer>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
))}
|
||||
})
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
))}
|
||||
|
||||
{status === "submitted" && <Shimmer className="text-sm">Thinking…</Shimmer>}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
||||
Something went wrong: {error.message}
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
{status === "submitted" && <Shimmer className="text-sm">Thinking…</Shimmer>}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
||||
Something went wrong: {error.message}
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
<div className="border-t px-4 py-3">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
{pending && (
|
||||
<div className="mb-2 text-center text-xs text-muted-foreground">
|
||||
Answer the question above to continue.
|
||||
{/* Floating composer: content fades out behind it. */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0">
|
||||
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
|
||||
<div className="bg-background pb-4">
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -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 { useRouter } from "next/navigation";
|
||||
import { WaypointsIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SessionDrawer } from "@/components/session-drawer";
|
||||
import { CornerDownLeftIcon, WaypointsIcon } from "lucide-react";
|
||||
import { SidebarToggle } from "@/components/sidebar";
|
||||
|
||||
/**
|
||||
* Home — the knowledge graph is the main view (placeholder until the graph
|
||||
* pipeline lands), with the new-session input docked below it. Sessions live
|
||||
* in the hamburger drawer.
|
||||
* pipeline lands), with the new-session blob docked below it. Sessions live
|
||||
* in the toggleable sidebar.
|
||||
*/
|
||||
export function Home() {
|
||||
const router = useRouter();
|
||||
@@ -37,7 +35,7 @@ export function Home() {
|
||||
return (
|
||||
<div className="flex h-dvh flex-col">
|
||||
<header className="flex items-center gap-2 px-3 py-2">
|
||||
<SessionDrawer />
|
||||
<SidebarToggle />
|
||||
</header>
|
||||
|
||||
{/* Graph — the main view. Placeholder until the knowledge graph lands. */}
|
||||
@@ -52,26 +50,40 @@ export function Home() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* New session input */}
|
||||
<div className="px-4 pb-6">
|
||||
<form
|
||||
className="mx-auto flex w-full max-w-2xl gap-2"
|
||||
{/* New session — same floating blob as the chat composer. */}
|
||||
<div className="pb-4">
|
||||
<div className="mx-auto w-full max-w-3xl px-4">
|
||||
<form
|
||||
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void start();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
<textarea
|
||||
value={topic}
|
||||
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?"
|
||||
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">
|
||||
{busy ? "Starting…" : "Start"}
|
||||
</Button>
|
||||
</form>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Start session"
|
||||
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>
|
||||
);
|
||||
|
||||
+32
-18
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MessageResponse } from "@/components/ai-elements/message";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -26,16 +26,29 @@ import {
|
||||
*
|
||||
* Quiz: graded (single/multi-select + "I don't know").
|
||||
* 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 {
|
||||
toolName: "quiz" | "ask_user_question";
|
||||
toolCallId: string;
|
||||
input: QuizToolInput | AskToolInput;
|
||||
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);
|
||||
|
||||
// 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 [showOther, setShowOther] = useState(false);
|
||||
const [other, setOther] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
// Free text lives in the composer blob: note (select modes) or answer (text mode).
|
||||
const note = pendingText;
|
||||
const text = pendingText;
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
setDontKnow(false);
|
||||
@@ -117,11 +131,19 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp
|
||||
values: [...selected].map((i) => qd.options[i]!.value),
|
||||
other: showOther ? other : undefined,
|
||||
text: qd.mode === "text" ? text : undefined,
|
||||
note: note.trim() || undefined,
|
||||
note: qd.mode !== "text" ? note.trim() || undefined : undefined,
|
||||
};
|
||||
onPendingTextChange("");
|
||||
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 (
|
||||
<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">
|
||||
@@ -200,24 +222,16 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(qd.mode === "text" || showOther) && (
|
||||
{showOther && (
|
||||
<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"
|
||||
rows={qd.mode === "text" ? 4 : 2}
|
||||
placeholder={qd.mode === "text" ? "Type your answer…" : "Type your own answer…"}
|
||||
value={qd.mode === "text" ? text : other}
|
||||
onChange={(e) => (qd.mode === "text" ? setText(e.target.value) : setOther(e.target.value))}
|
||||
rows={2}
|
||||
placeholder="Type your own answer…"
|
||||
value={other}
|
||||
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">
|
||||
<Button size="sm" onClick={submit} disabled={!canSubmit}>
|
||||
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 {
|
||||
toolCallId: 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 {
|
||||
@@ -16,7 +18,7 @@ export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | nu
|
||||
for (let p = msg.parts.length - 1; p >= 0; p--) {
|
||||
const part = msg.parts[p]!;
|
||||
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.
|
||||
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 ──
|
||||
const options = normalizeOptions(QUIZ_INPUT.options);
|
||||
|
||||
@@ -44,7 +44,7 @@ function quizQD(): QuestionData {
|
||||
describe("render smoke tests", () => {
|
||||
it("QuizCard renders question, options, and the automatic 'I don't know'", () => {
|
||||
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, /Mercury/);
|
||||
@@ -56,22 +56,25 @@ describe("render smoke tests", () => {
|
||||
|
||||
it("QuizCard never leaks the explanation or the correct answer before submit", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
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(
|
||||
<QuizCard
|
||||
toolName="ask_user_question"
|
||||
toolCallId="a1"
|
||||
input={{ question: "How confident are you?", options: [{ label: "Very" }, { label: "Somewhat" }] }}
|
||||
onSubmit={() => {}}
|
||||
pendingText=""
|
||||
onPendingTextChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
assert.match(html, /Other/);
|
||||
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)", () => {
|
||||
@@ -141,7 +144,7 @@ describe("math rendering (streamdown + KaTeX)", () => {
|
||||
explanation: "The power rule gives $\\frac{dy}{dx} = 2x$.",
|
||||
};
|
||||
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");
|
||||
|
||||
|
||||
@@ -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