DocDoeAI / src /components /study-chat /ConversationWorkspace.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
6.47 kB
"use client";
import type { ComponentProps, RefObject } from "react";
import { LoaderCircle, Plus } from "lucide-react";
import type { ApiDocument } from "@/lib/api/types";
import { studyIntentConfig, type StudyIntent } from "@/lib/study-intents";
import { cn } from "@/lib/utils";
import { QuickActions } from "./QuickActions";
import { StagedThinkingLine } from "./StagedThinking";
import { SelectedSourcesStrip } from "./SourceControls";
import { PlainAssistantMessage, StudyCardRenderer } from "./StudyCards";
import { StudyComposer } from "./StudyComposer";
import type { ChatMessage, StudyActionHandler } from "./types";
type ConversationWorkspaceProps = {
activeIntent: StudyIntent;
composerProps: ComponentProps<typeof StudyComposer>;
documents: ApiDocument[];
isLoading: boolean;
messages: ChatMessage[];
onAction: StudyActionHandler;
onClearSources: () => void;
onNewChat: () => void;
onStartRender?: (topic: string, sourceId: string | null, minutes: number) => void;
onToggleSource: (sourceId: string) => void;
onUpload?: () => void;
selectedDocuments: ApiDocument[];
threadEndRef: RefObject<HTMLDivElement | null>;
};
export function ConversationWorkspace({
activeIntent,
composerProps,
documents,
isLoading,
messages,
onAction,
onClearSources,
onNewChat,
onStartRender,
onToggleSource,
onUpload,
selectedDocuments,
threadEndRef,
}: ConversationWorkspaceProps) {
const intentLabel = studyIntentConfig[activeIntent].shortLabel ?? studyIntentConfig[activeIntent].label;
const lastMessage = messages[messages.length - 1];
const showTypingIndicator =
isLoading && (!lastMessage || lastMessage.role !== "assistant" || !lastMessage.card || lastMessage.card.kind !== "loading");
return (
<>
<div className="kb-thread-scroll min-h-0 flex-1 overflow-y-auto px-4 pb-6 pt-4 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-4xl xl:max-w-[min(100%,52rem)]">
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-white/32">Study Chat</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<h1 className="text-lg font-semibold text-white sm:text-xl">Study session</h1>
<span className="inline-flex items-center rounded-full border border-white/[0.1] bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold text-white/52">
{intentLabel}
</span>
</div>
</div>
<button
type="button"
onClick={onNewChat}
className="inline-flex min-h-10 items-center gap-2 rounded-xl border border-white/[0.1] bg-white/[0.035] px-3 text-sm font-semibold text-white/62 transition hover:border-white/[0.18] hover:bg-white/[0.05] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/22"
>
<Plus className="size-4" />
New chat
</button>
</div>
<SelectedSourcesStrip
selectedDocuments={selectedDocuments}
documents={documents}
onToggleSource={onToggleSource}
onClear={onClearSources}
/>
<ConversationThread
messages={messages}
onAction={onAction}
onUpload={onUpload}
onStartRender={onStartRender}
showTypingIndicator={showTypingIndicator}
activeIntent={activeIntent}
threadEndRef={threadEndRef}
/>
</div>
</div>
<div className="kb-composer-dock relative shrink-0 border-t border-white/[0.075] bg-[#030303]/92 p-3 pb-[calc(5rem+env(safe-area-inset-bottom))] backdrop-blur-xl lg:p-4 lg:pb-4">
<div className="pointer-events-none absolute inset-x-0 -top-8 h-8 bg-gradient-to-t from-[#030303] to-transparent" aria-hidden="true" />
<div className="mx-auto w-full max-w-4xl xl:max-w-[min(100%,52rem)]">
<StudyComposer {...composerProps} compact />
<div className="mt-3 hidden lg:block">
<QuickActions activeIntent={activeIntent} disabled={isLoading} onAction={onAction} layout="grid" />
</div>
</div>
</div>
</>
);
}
function ConversationThread({
messages,
onAction,
onUpload,
onStartRender,
showTypingIndicator,
activeIntent,
threadEndRef,
}: {
messages: ChatMessage[];
onAction: StudyActionHandler;
onUpload?: () => void;
onStartRender?: (topic: string, sourceId: string | null, minutes: number) => void;
showTypingIndicator: boolean;
activeIntent: StudyIntent;
threadEndRef: RefObject<HTMLDivElement | null>;
}) {
return (
<div className="mt-5 space-y-6">
{messages.map((message) => (
<div key={message.id} className={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}>
{message.role === "user" ? (
<div className="animate-msg-user max-w-[min(42rem,88vw)] rounded-[1.15rem] border border-white/[0.09] bg-white px-4 py-3 text-sm leading-6 text-black shadow-[0_14px_40px_rgba(0,0,0,0.24)]">
{message.text}
</div>
) : message.card ? (
<div className="w-full">
<StudyCardRenderer card={message.card} onAction={onAction} onUpload={onUpload} onStartRender={onStartRender} />
</div>
) : (
<PlainAssistantMessage text={message.text} />
)}
</div>
))}
{showTypingIndicator ? <TypingIndicator intent={activeIntent} /> : null}
<div ref={threadEndRef} />
</div>
);
}
function TypingIndicator({ intent }: { intent: StudyIntent }) {
return (
<div className="flex justify-start">
<div
className="animate-msg-assistant inline-flex items-center gap-3 rounded-[1.15rem] border border-white/[0.08] bg-white/[0.03] px-4 py-3 text-sm text-white/52"
aria-live="polite"
aria-label="DocDoe is preparing your answer"
>
<LoaderCircle className="size-4 shrink-0 animate-spin text-white/42" />
<span className="inline-flex items-center gap-1">
<span className="typing-dot" />
<span className="typing-dot" />
<span className="typing-dot" />
</span>
<StagedThinkingLine intent={intent} />
</div>
</div>
);
}