File size: 6,469 Bytes
65d68f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 | "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>
);
}
|