feat: add AI Agent chat integration with Anthropic Claude API

Add AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled on Write page (replaces textarea) and optionally in RightSidebar on posts/pages via frontmatter.

Features:
- AIChatView component with per-page chat history
- Page content context support for AI responses
- Markdown rendering for AI responses
- User-friendly error handling for missing API keys
- System prompt configurable via Convex environment variables
- Anonymous session authentication using localStorage

Environment variables required:
- ANTHROPIC_API_KEY (required)
- CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES (optional split prompts)
- CLAUDE_SYSTEM_PROMPT (optional single prompt fallback)

Configuration:
- siteConfig.aiChat.enabledOnWritePage: Enable Agent toggle on /write page
- siteConfig.aiChat.enabledOnContent: Allow Agent on posts/pages via frontmatter
- Frontmatter aiChat: true (requires rightSidebar: true)

Updated files:
- src/components/AIChatView.tsx: AI chat interface component
- src/components/RightSidebar.tsx: Conditional Agent rendering
- src/pages/Write.tsx: Agent mode toggle (title changes to Agent)
- convex/aiChats.ts: Chat history queries and mutations
- convex/aiChatActions.ts: Claude API integration with error handling
- convex/schema.ts: aiChats table with indexes
- src/config/siteConfig.ts: AIChatConfig interface
- Documentation updated across all files

Documentation:
- files.md: Updated component descriptions
- changelog.md: Added v1.33.0 entry
- TASK.md: Marked AI chat tasks as completed
- README.md: Added AI Agent Chat section
- content/pages/docs.md: Added AI Agent chat documentation
- content/blog/setup-guide.md: Added AI Agent chat setup instructions
- public/raw/changelog.md: Added v1.33.0 entry
This commit is contained in:
Wayne Sutton
2025-12-26 12:31:33 -08:00
parent 50890e9153
commit bfe88d0217
34 changed files with 3867 additions and 245 deletions

View File

@@ -20,11 +20,40 @@ function App() {
return <Write />;
}
// Determine if we should use a custom homepage
const useCustomHomepage =
siteConfig.homepage.type !== "default" && siteConfig.homepage.slug;
return (
<SidebarProvider>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
{/* Homepage route - either default Home or custom page/post */}
<Route
path="/"
element={
useCustomHomepage ? (
<Post
slug={siteConfig.homepage.slug!}
isHomepage={true}
homepageType={
siteConfig.homepage.type === "default"
? undefined
: siteConfig.homepage.type
}
/>
) : (
<Home />
)
}
/>
{/* Original homepage route (when custom homepage is set) */}
{useCustomHomepage && (
<Route
path={siteConfig.homepage.originalHomeRoute || "/home"}
element={<Home />}
/>
)}
<Route path="/stats" element={<Stats />} />
{/* Blog page route - only enabled when blogPage.enabled is true */}
{siteConfig.blogPage.enabled && (

View File

@@ -0,0 +1,719 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useQuery, useMutation, useAction } from "convex/react";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
PaperPlaneTilt,
Copy,
Check,
Stop,
Trash,
FileText,
SpinnerGap,
Image,
Link,
X,
} from "@phosphor-icons/react";
// Generate a unique session ID for anonymous users
function getSessionId(): string {
const key = "ai_chat_session_id";
let sessionId = localStorage.getItem(key);
if (!sessionId) {
sessionId = crypto.randomUUID();
localStorage.setItem(key, sessionId);
}
return sessionId;
}
interface AIChatViewProps {
contextId: string; // Slug or "write-page"
pageContent?: string; // Optional page content for context
onClose?: () => void; // Optional close handler
hideAttachments?: boolean; // Hide image/link attachment buttons (for right sidebar)
}
export default function AIChatView({
contextId,
pageContent,
onClose,
hideAttachments = false,
}: AIChatViewProps) {
// State
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isStopped, setIsStopped] = useState(false);
const [copiedMessageIndex, setCopiedMessageIndex] = useState<number | null>(
null,
);
const [chatId, setChatId] = useState<Id<"aiChats"> | null>(null);
const [hasLoadedContext, setHasLoadedContext] = useState(false);
const [error, setError] = useState<string | null>(null);
const [attachments, setAttachments] = useState<
Array<{
type: "image" | "link";
storageId?: Id<"_storage">;
url?: string;
file?: File;
preview?: string;
scrapedContent?: string;
title?: string;
}>
>([]);
const [isUploading, setIsUploading] = useState(false);
const [linkInputValue, setLinkInputValue] = useState("");
const [showLinkModal, setShowLinkModal] = useState(false);
// Refs
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// Session ID
const sessionId = getSessionId();
// Convex hooks
const chat = useQuery(
api.aiChats.getAIChatByContext,
chatId ? { sessionId, contextId } : "skip",
);
const getOrCreateChat = useMutation(api.aiChats.getOrCreateAIChat);
const addUserMessage = useMutation(api.aiChats.addUserMessage);
const addUserMessageWithAttachments = useMutation(
api.aiChats.addUserMessageWithAttachments,
);
const generateUploadUrl = useMutation(api.aiChats.generateUploadUrl);
const clearChatMutation = useMutation(api.aiChats.clearChat);
const setPageContext = useMutation(api.aiChats.setPageContext);
const generateResponse = useAction(api.aiChatActions.generateResponse);
// Initialize chat
useEffect(() => {
const initChat = async () => {
const id = await getOrCreateChat({ sessionId, contextId });
setChatId(id);
};
initChat();
}, [sessionId, contextId, getOrCreateChat]);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [chat?.messages]);
// Prevent page scroll when clicking input container
const handleInputContainerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only prevent if clicking the container itself, not the textarea
if (e.target === e.currentTarget) {
e.preventDefault();
e.stopPropagation();
textareaRef.current?.focus({ preventScroll: true });
}
},
[],
);
// Focus input after mount with delay to prevent scroll jump
useEffect(() => {
// Use setTimeout to delay focus until after layout settles
const timeoutId = setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.focus({ preventScroll: true });
}
}, 100);
return () => clearTimeout(timeoutId);
}, []);
// Auto-resize textarea
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
}, []);
useEffect(() => {
adjustTextareaHeight();
}, [inputValue, adjustTextareaHeight]);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// "/" to focus input (when not in input/textarea)
if (
e.key === "/" &&
document.activeElement?.tagName !== "INPUT" &&
document.activeElement?.tagName !== "TEXTAREA"
) {
e.preventDefault();
textareaRef.current?.focus({ preventScroll: true });
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
// Handle image upload
const handleImageUpload = async (file: File) => {
if (!chatId) return;
// Check attachment limit (max 3 images)
const currentImageCount = attachments.filter(
(a) => a.type === "image",
).length;
if (currentImageCount >= 3) {
alert("Maximum 3 images per message");
return;
}
// Validate file type
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
if (!validTypes.includes(file.type)) {
alert("Please upload a PNG, JPEG, GIF, or WebP image");
return;
}
// Validate file size (3MB max)
const maxSize = 3 * 1024 * 1024;
if (file.size > maxSize) {
alert("Image must be smaller than 3MB");
return;
}
setIsUploading(true);
try {
// Get upload URL
const uploadUrl = await generateUploadUrl();
// Upload file
const result = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!result.ok) {
throw new Error("Upload failed");
}
const response = await result.json();
const storageId = response.storageId;
// Add to attachments
const preview = URL.createObjectURL(file);
setAttachments((prev) => [
...prev,
{
type: "image",
storageId: storageId as Id<"_storage">,
file,
preview,
},
]);
} catch (error) {
console.error("Error uploading image:", error);
alert("Failed to upload image");
} finally {
setIsUploading(false);
}
};
// Handle link attachment
const handleAddLink = () => {
if (!linkInputValue.trim()) return;
// Check attachment limit (max 3 links)
const currentLinkCount = attachments.filter(
(a) => a.type === "link",
).length;
if (currentLinkCount >= 3) {
alert("Maximum 3 links per message");
return;
}
const url = linkInputValue.trim();
try {
new URL(url); // Validate URL
setAttachments((prev) => [
...prev,
{
type: "link",
url,
},
]);
setLinkInputValue("");
setShowLinkModal(false);
} catch {
alert("Please enter a valid URL");
}
};
// Remove attachment
const handleRemoveAttachment = (index: number) => {
setAttachments((prev) => {
const newAttachments = [...prev];
const removed = newAttachments[index];
if (removed.preview) {
URL.revokeObjectURL(removed.preview);
}
newAttachments.splice(index, 1);
return newAttachments;
});
};
// Handle send message
const handleSend = async () => {
if (
(!inputValue.trim() && attachments.length === 0) ||
!chatId ||
isLoading
)
return;
const message = inputValue.trim();
setInputValue("");
setIsStopped(false);
// Handle clear command
if (message.toLowerCase() === "clear") {
await clearChatMutation({ chatId });
setHasLoadedContext(false);
setAttachments([]);
return;
}
// Prepare attachments for sending
const attachmentsToSend = attachments.map((att) => ({
type: att.type as "image" | "link",
storageId: att.storageId,
url: att.url,
scrapedContent: att.scrapedContent,
title: att.title,
}));
// Add user message with attachments
if (attachmentsToSend.length > 0) {
await addUserMessageWithAttachments({
chatId,
content: message || "",
attachments: attachmentsToSend,
});
} else {
await addUserMessage({ chatId, content: message });
}
// Clear attachments
attachments.forEach((att) => {
if (att.preview) {
URL.revokeObjectURL(att.preview);
}
});
setAttachments([]);
// Generate AI response
setIsLoading(true);
setIsStopped(false);
setError(null);
abortControllerRef.current = new AbortController();
try {
await generateResponse({
chatId,
userMessage: message || "",
pageContext: hasLoadedContext ? undefined : pageContent,
attachments:
attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
});
} catch (error) {
if ((error as Error).name !== "AbortError") {
const errorMessage =
(error as Error).message || "Failed to generate response";
setError(errorMessage);
console.error("Error generating response:", error);
}
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
};
// Handle stop generation
const handleStop = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
setIsStopped(true);
setIsLoading(false);
}
};
// Handle key press in textarea
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Handle copy message
const handleCopy = async (content: string, index: number) => {
await navigator.clipboard.writeText(content);
setCopiedMessageIndex(index);
setTimeout(() => setCopiedMessageIndex(null), 2000);
};
// Handle load page context
const handleLoadContext = async () => {
if (!chatId || !pageContent) return;
await setPageContext({ chatId, pageContext: pageContent });
setHasLoadedContext(true);
};
// Handle clear chat
const handleClear = async () => {
if (!chatId) return;
await clearChatMutation({ chatId });
setHasLoadedContext(false);
setError(null);
};
const messages = chat?.messages || [];
// Component to render image attachment with URL fetching
const ImageAttachment = ({ storageId }: { storageId: Id<"_storage"> }) => {
const imageUrl = useQuery(api.aiChats.getStorageUrl, { storageId });
if (!imageUrl) {
return <div className="ai-chat-attachment-loading">Loading image...</div>;
}
return (
<img
src={imageUrl}
alt="Attachment"
className="ai-chat-attachment-image"
loading="lazy"
/>
);
};
return (
<div className="ai-chat-view">
{/* Header with actions */}
<div className="ai-chat-header">
<span className="ai-chat-title">Agent</span>
<div className="ai-chat-header-actions">
{pageContent && !hasLoadedContext && (
<button
className="ai-chat-load-context-button"
onClick={handleLoadContext}
title="Load page content as context"
>
<FileText size={16} weight="bold" />
<span>Load Page</span>
</button>
)}
{pageContent && hasLoadedContext && (
<span className="ai-chat-context-loaded">
<Check size={14} weight="bold" />
Context loaded
</span>
)}
<button
className="ai-chat-clear-button"
onClick={handleClear}
title="Clear chat (or type 'clear')"
disabled={messages.length === 0}
>
<Trash size={16} weight="bold" />
</button>
{onClose && (
<button
className="ai-chat-close-button"
onClick={onClose}
title="Close chat"
>
Back to Editor
</button>
)}
</div>
</div>
{/* Messages */}
<div className="ai-chat-messages">
{messages.length === 0 && !isLoading && (
<div className="ai-chat-empty">
<p>Ask a question.</p>
<p className="ai-chat-empty-hint">
Press Enter to send, Shift+Enter for new line, or type
&quot;clear&quot; to reset.
</p>
</div>
)}
{messages.map((message, index) => (
<div
key={`${message.timestamp}-${index}`}
className={`ai-chat-message ai-chat-message-${message.role}`}
>
<div className="ai-chat-message-content">
{message.role === "assistant" ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
) : (
<>
{message.content && <p>{message.content}</p>}
{message.attachments && message.attachments.length > 0 && (
<div className="ai-chat-attachments">
{message.attachments.map((att, attIndex) => (
<div key={attIndex} className="ai-chat-attachment">
{att.type === "image" && att.storageId && (
<ImageAttachment storageId={att.storageId} />
)}
{att.type === "link" && att.url && (
<a
href={att.url}
target="_blank"
rel="noopener noreferrer"
className="ai-chat-attachment-link"
>
<Link size={16} />
<span>{att.title || att.url}</span>
</a>
)}
</div>
))}
</div>
)}
</>
)}
</div>
{message.role === "assistant" && (
<button
className="ai-chat-copy-button"
onClick={() => handleCopy(message.content, index)}
title="Copy message"
>
{copiedMessageIndex === index ? (
<Check size={14} weight="bold" />
) : (
<Copy size={14} weight="bold" />
)}
</button>
)}
</div>
))}
{/* Loading state */}
{isLoading && (
<div className="ai-chat-message ai-chat-message-assistant ai-chat-loading">
<div className="ai-chat-loading-content">
<SpinnerGap size={18} weight="bold" className="ai-chat-spinner" />
<span>Thinking...</span>
</div>
<button
className="ai-chat-stop-button"
onClick={handleStop}
title="Stop generating"
>
<Stop size={16} weight="bold" />
<span>Stop</span>
</button>
</div>
)}
{/* Stopped state */}
{isStopped && !isLoading && (
<div className="ai-chat-stopped">Generation stopped</div>
)}
{/* Error state */}
{error && (
<div className="ai-chat-message ai-chat-message-assistant ai-chat-error">
<div className="ai-chat-message-content">
<p style={{ margin: 0 }}>{error}</p>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Attachments preview */}
{attachments.length > 0 && (
<div className="ai-chat-attachments-preview">
{attachments.map((att, index) => (
<div key={index} className="ai-chat-attachment-preview">
{att.type === "image" && att.preview && (
<>
<img
src={att.preview}
alt="Preview"
className="ai-chat-attachment-preview-image"
/>
<button
className="ai-chat-attachment-remove"
onClick={() => handleRemoveAttachment(index)}
title="Remove attachment"
>
<X size={14} weight="bold" />
</button>
</>
)}
{att.type === "link" && (
<>
<Link size={16} />
<span className="ai-chat-attachment-preview-url">
{att.url}
</span>
<button
className="ai-chat-attachment-remove"
onClick={() => handleRemoveAttachment(index)}
title="Remove attachment"
>
<X size={14} weight="bold" />
</button>
</>
)}
</div>
))}
</div>
)}
{/* Input area */}
<div
className="ai-chat-input-container"
onClick={handleInputContainerClick}
>
<div className="ai-chat-input-wrapper">
{!hideAttachments && (
<div className="ai-chat-input-actions">
<label
className="ai-chat-attach-button"
title="Upload image (max 3)"
>
<input
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleImageUpload(file);
}
e.target.value = ""; // Reset input
}}
disabled={
isLoading ||
isUploading ||
attachments.filter((a) => a.type === "image").length >= 3
}
/>
{isUploading ? (
<SpinnerGap
size={18}
weight="bold"
className="ai-chat-spinner"
/>
) : (
<Image size={18} weight="regular" />
)}
</label>
<button
className="ai-chat-attach-button"
onClick={() => setShowLinkModal(true)}
disabled={
isLoading ||
isUploading ||
attachments.filter((a) => a.type === "link").length >= 3
}
title="Add link (max 3)"
>
<Link size={18} weight="regular" />
</button>
</div>
)}
<textarea
ref={textareaRef}
className="ai-chat-input"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Type your message..."
rows={1}
disabled={isLoading}
/>
<button
className="ai-chat-send-button"
onClick={handleSend}
disabled={
(!inputValue.trim() && attachments.length === 0) || isLoading
}
title="Send message (Enter)"
>
<PaperPlaneTilt size={18} weight="bold" />
</button>
</div>
</div>
{/* Link input modal */}
{showLinkModal && (
<div
className="ai-chat-link-modal"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowLinkModal(false);
setLinkInputValue("");
}
}}
>
<div className="ai-chat-link-modal-content">
<h3>Add Link</h3>
<input
type="url"
value={linkInputValue}
onChange={(e) => setLinkInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAddLink();
} else if (e.key === "Escape") {
setShowLinkModal(false);
setLinkInputValue("");
}
}}
placeholder="https://example.com"
className="ai-chat-link-input"
autoFocus
/>
<div className="ai-chat-link-modal-actions">
<button
onClick={() => {
setShowLinkModal(false);
setLinkInputValue("");
}}
className="ai-chat-link-modal-cancel"
>
Cancel
</button>
<button
onClick={handleAddLink}
className="ai-chat-link-modal-add"
disabled={!linkInputValue.trim()}
>
Add
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -37,19 +37,23 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
// Normalize images
const normalizedImages = config.images.map(normalizeImage);
// Check if scrolling mode (default true for backwards compatibility)
const isScrolling = config.scrolling !== false;
// home logos scrolling settings
const isScrolling = config.scrolling !== true;
// For static mode, limit to maxItems (default 4)
const maxItems = config.maxItems ?? 4;
const displayImages = isScrolling
const displayImages = isScrolling
? [...normalizedImages, ...normalizedImages] // Duplicate for seamless scroll
: normalizedImages.slice(0, maxItems); // Limit for static grid
// Render logo item (shared between modes)
const renderLogo = (logo: LogoItem, index: number) => (
<div key={`${logo.src}-${index}`} className={isScrolling ? "logo-marquee-item" : "logo-static-item"}>
<div
key={`${logo.src}-${index}`}
className={isScrolling ? "logo-marquee-item" : "logo-static-item"}
>
{logo.href ? (
<a
href={logo.href}
@@ -77,9 +81,7 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
return (
<div className="logo-marquee-container">
{config.title && (
<p className="logo-marquee-title">{config.title}</p>
)}
{config.title && <p className="logo-marquee-title">{config.title}</p>}
{isScrolling ? (
// Scrolling marquee mode
<div
@@ -96,9 +98,7 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
</div>
) : (
// Static grid mode
<div className="logo-static-grid">
{displayImages.map(renderLogo)}
</div>
<div className="logo-static-grid">{displayImages.map(renderLogo)}</div>
)}
</div>
);

View File

@@ -1,12 +1,44 @@
// Right sidebar component - maintains layout spacing when sidebars are enabled
// CopyPageDropdown is now rendered in the main content area instead
export default function RightSidebar() {
// Right sidebar component
// Conditionally renders AI chat when enabled via frontmatter and siteConfig
import AIChatView from "./AIChatView";
import siteConfig from "../config/siteConfig";
interface RightSidebarProps {
aiChatEnabled?: boolean; // From frontmatter aiChat: true
pageContent?: string; // Page markdown content for AI context
slug?: string; // Page/post slug for chat context ID
}
export default function RightSidebar({
aiChatEnabled = false,
pageContent,
slug,
}: RightSidebarProps) {
// Check if AI chat should be shown
// Requires both siteConfig.aiChat.enabledOnContent AND frontmatter aiChat: true
const showAIChat =
siteConfig.aiChat.enabledOnContent && aiChatEnabled && slug;
if (showAIChat) {
return (
<aside className="post-sidebar-right">
<div className="right-sidebar-ai-chat">
<AIChatView
contextId={slug}
pageContent={pageContent}
hideAttachments={true}
/>
</div>
</aside>
);
}
// Default empty sidebar for layout spacing
return (
<aside className="post-sidebar-right">
<div className="right-sidebar-content">
{/* Empty - CopyPageDropdown moved to main content area */}
{/* Empty - maintains layout spacing */}
</div>
</aside>
);
}

View File

@@ -98,6 +98,21 @@ export interface FooterConfig {
defaultContent?: string; // Default markdown content if no frontmatter footer field provided
}
// Homepage configuration
// Allows setting any page or blog post to serve as the homepage
export interface HomepageConfig {
type: "default" | "page" | "post"; // Type of homepage: default (standard Home component), page (static page), or post (blog post)
slug?: string; // Required if type is "page" or "post" - the slug of the page/post to use as homepage
originalHomeRoute?: string; // Route to access the original homepage when custom homepage is set (default: "/home")
}
// AI Chat configuration
// Controls the AI writing assistant feature on Write page and content pages
export interface AIChatConfig {
enabledOnWritePage: boolean; // Show AI chat toggle on /write page
enabledOnContent: boolean; // Allow AI chat on posts/pages via frontmatter aiChat: true
}
// Site configuration interface
export interface SiteConfig {
// Basic site info
@@ -150,6 +165,12 @@ export interface SiteConfig {
// Footer configuration
footer: FooterConfig;
// Homepage configuration
homepage: HomepageConfig;
// AI Chat configuration
aiChat: AIChatConfig;
}
// Default site configuration
@@ -186,7 +207,7 @@ export const siteConfig: SiteConfig = {
},
{
src: "/images/logos/netlify.svg",
href: "https://www.netlify.com/",
href: "https://www.netlify.com/utm_source=markdownfast",
},
{
src: "/images/logos/firecrawl.svg",
@@ -201,8 +222,8 @@ export const siteConfig: SiteConfig = {
href: "https://markdown.fast/setup-guide",
},
{
src: "/images/logos/sample-logo-5.svg",
href: "https://markdown.fast/setup-guide",
src: "/images/logos/agentmail.svg",
href: "https://agentmail.to/utm_source=markdownfast",
},
],
position: "above-footer",
@@ -321,6 +342,24 @@ export const siteConfig: SiteConfig = {
Created by [Wayne](https://x.com/waynesutton) with Convex, Cursor, and Claude Opus 4.5. Follow on [Twitter/X](https://x.com/waynesutton), [LinkedIn](https://www.linkedin.com/in/waynesutton/), and [GitHub](https://github.com/waynesutton). This project is licensed under the MIT [License](https://github.com/waynesutton/markdown-site?tab=MIT-1-ov-file).`,
},
// Homepage configuration
// Set any page or blog post to serve as the homepage
// Custom homepage uses the page/post's full content and features (sidebar, copy dropdown, etc.)
// Featured section is NOT shown on custom homepage (only on default Home component)
homepage: {
type: "default", // Options: "default" (standard Home component), "page" (use a static page), or "post" (use a blog post)
slug: "undefined", // Required if type is "page" or "post" - the slug of the page/post to use default is undefined
originalHomeRoute: "/home", // Route to access the original homepage when custom homepage is set
},
// AI Chat configuration
// Controls the AI writing assistant powered by Claude
// Requires ANTHROPIC_API_KEY environment variable in Convex dashboard
aiChat: {
enabledOnWritePage: true, // Show AI chat toggle on /write page
enabledOnContent: true, // Allow AI chat on posts/pages via frontmatter aiChat: true
},
};
// Export the config as default for easy importing

View File

@@ -18,11 +18,25 @@ const SITE_URL = "https://markdown.fast";
const SITE_NAME = "markdown sync framework";
const DEFAULT_OG_IMAGE = "/images/og-default.svg";
export default function Post() {
const { slug } = useParams<{ slug: string }>();
interface PostProps {
slug?: string; // Optional slug prop when used as homepage
isHomepage?: boolean; // Flag to indicate this is the homepage
homepageType?: "page" | "post"; // Type of homepage content
}
export default function Post({
slug: propSlug,
isHomepage = false,
homepageType,
}: PostProps = {}) {
const { slug: routeSlug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const location = useLocation();
const { setHeadings, setActiveId } = useSidebar();
// Use prop slug if provided (for homepage), otherwise use route slug
const slug = propSlug || routeSlug;
// Check for page first, then post
const page = useQuery(api.pages.getPageBySlug, slug ? { slug } : "skip");
const post = useQuery(api.posts.getPostBySlug, slug ? { slug } : "skip");
@@ -196,8 +210,8 @@ export default function Post() {
return (
<div className={`post-page ${hasAnySidebar ? "post-page-with-sidebar" : ""}`}>
<nav className={`post-nav ${hasAnySidebar ? "post-nav-with-sidebar" : ""}`}>
{/* Hide back-button when sidebars are enabled */}
{!hasAnySidebar && (
{/* Hide back-button when sidebars are enabled or when used as homepage */}
{!hasAnySidebar && !isHomepage && (
<button onClick={() => navigate("/")} className="back-button">
<ArrowLeft size={16} />
<span>Back</span>
@@ -269,8 +283,14 @@ export default function Post() {
)}
</article>
{/* Right sidebar - empty when sidebars are enabled, CopyPageDropdown moved to main content */}
{hasRightSidebar && <RightSidebar />}
{/* Right sidebar - with optional AI chat support */}
{hasRightSidebar && (
<RightSidebar
aiChatEnabled={page.aiChat}
pageContent={page.content}
slug={page.slug}
/>
)}
</div>
</div>
);
@@ -319,8 +339,8 @@ export default function Post() {
return (
<div className={`post-page ${hasAnySidebar ? "post-page-with-sidebar" : ""}`}>
<nav className={`post-nav ${hasAnySidebar ? "post-nav-with-sidebar" : ""}`}>
{/* Hide back-button when sidebars are enabled */}
{!hasAnySidebar && (
{/* Hide back-button when sidebars are enabled or when used as homepage */}
{!hasAnySidebar && !isHomepage && (
<button onClick={() => navigate("/")} className="back-button">
<ArrowLeft size={16} />
<span>Back</span>
@@ -475,8 +495,14 @@ export default function Post() {
)}
</article>
{/* Right sidebar - empty when sidebars are enabled, CopyPageDropdown moved to main content */}
{hasRightSidebar && <RightSidebar />}
{/* Right sidebar - with optional AI chat support */}
{hasRightSidebar && (
<RightSidebar
aiChatEnabled={post.aiChat}
pageContent={post.content}
slug={post.slug}
/>
)}
</div>
</div>
);

View File

@@ -9,11 +9,14 @@ import {
File,
Warning,
TextAa,
ChatCircle,
} from "@phosphor-icons/react";
import { Moon, Sun, Cloud } from "lucide-react";
import { Half2Icon } from "@radix-ui/react-icons";
import { useTheme } from "../context/ThemeContext";
import { useFont } from "../context/FontContext";
import AIChatView from "../components/AIChatView";
import siteConfig from "../config/siteConfig";
// Frontmatter field definitions for blog posts
const POST_FIELDS = [
@@ -37,11 +40,20 @@ const POST_FIELDS = [
{ name: "featured", required: false, example: "true" },
{ name: "featuredOrder", required: false, example: "1" },
{ name: "authorName", required: false, example: '"Jane Doe"' },
{ name: "authorImage", required: false, example: '"/images/authors/jane.png"' },
{
name: "authorImage",
required: false,
example: '"/images/authors/jane.png"',
},
{ name: "layout", required: false, example: '"sidebar"' },
{ name: "rightSidebar", required: false, example: "true" },
{ name: "showFooter", required: false, example: "true" },
{ name: "footer", required: false, example: '"Built with [Convex](https://convex.dev)."' },
{
name: "footer",
required: false,
example: '"Built with [Convex](https://convex.dev)."',
},
{ name: "aiChat", required: false, example: "true" },
];
// Frontmatter field definitions for pages
@@ -56,11 +68,20 @@ const PAGE_FIELDS = [
{ name: "featured", required: false, example: "true" },
{ name: "featuredOrder", required: false, example: "1" },
{ name: "authorName", required: false, example: '"Jane Doe"' },
{ name: "authorImage", required: false, example: '"/images/authors/jane.png"' },
{
name: "authorImage",
required: false,
example: '"/images/authors/jane.png"',
},
{ name: "layout", required: false, example: '"sidebar"' },
{ name: "rightSidebar", required: false, example: "true" },
{ name: "showFooter", required: false, example: "true" },
{ name: "footer", required: false, example: '"Built with [Convex](https://convex.dev)."' },
{
name: "footer",
required: false,
example: '"Built with [Convex](https://convex.dev)."',
},
{ name: "aiChat", required: false, example: "true" },
];
// Generate frontmatter template based on content type
@@ -160,8 +181,12 @@ export default function Write() {
const [copied, setCopied] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [font, setFont] = useState<"serif" | "sans" | "monospace">("serif");
const [isAIChatMode, setIsAIChatMode] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Check if AI chat is enabled for write page
const aiChatEnabled = siteConfig.aiChat.enabledOnWritePage;
// Load from localStorage on mount
useEffect(() => {
const savedContent = localStorage.getItem(STORAGE_KEY_CONTENT);
@@ -188,7 +213,9 @@ export default function Write() {
// Use saved font preference, or fall back to global font, or default to serif
if (
savedFont &&
(savedFont === "serif" || savedFont === "sans" || savedFont === "monospace")
(savedFont === "serif" ||
savedFont === "sans" ||
savedFont === "monospace")
) {
setFont(savedFont);
} else {
@@ -212,6 +239,12 @@ export default function Write() {
localStorage.setItem(STORAGE_KEY_FONT, font);
}, [font]);
// Prevent scroll when switching to AI chat mode
useEffect(() => {
// Lock scroll position to prevent jump when AI chat mounts
window.scrollTo(0, 0);
}, [isAIChatMode]);
// Toggle font between serif, sans-serif, and monospace
const toggleFont = useCallback(() => {
setFont((prev) => {
@@ -325,6 +358,34 @@ export default function Write() {
<div className="write-nav-section">
<span className="write-nav-label">Actions</span>
{/* AI Chat toggle - only show if enabled in siteConfig */}
{aiChatEnabled && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Prevent any scroll behavior during mode switch
const scrollX = window.scrollX;
const scrollY = window.scrollY;
setIsAIChatMode(!isAIChatMode);
// Restore scroll position immediately after state change
requestAnimationFrame(() => {
window.scrollTo(scrollX, scrollY);
});
}}
className={`write-nav-item ${isAIChatMode ? "active" : ""}`}
title={
isAIChatMode ? "Switch to text editor" : "Switch to AI Chat"
}
>
<ChatCircle
size={18}
weight={isAIChatMode ? "fill" : "regular"}
/>
<span>{isAIChatMode ? "Text Editor" : "Agent"}</span>
</button>
)}
<button onClick={handleClear} className="write-nav-item">
<Trash size={18} />
<span>Clear</span>
@@ -357,54 +418,68 @@ export default function Write() {
<main className="write-main">
<div className="write-main-header">
<h1 className="write-main-title">
{contentType === "post" ? "Blog Post" : "Page"}
{isAIChatMode
? "Agent"
: contentType === "post"
? "Blog Post"
: "Page"}
</h1>
<button
onClick={handleCopy}
className={`write-copy-btn ${copied ? "copied" : ""}`}
>
{copied ? (
<>
<Check size={16} weight="bold" />
<span>Copied</span>
</>
) : (
<>
<CopySimple size={16} />
<span>Copy All</span>
</>
)}
</button>
{!isAIChatMode && (
<button
onClick={handleCopy}
className={`write-copy-btn ${copied ? "copied" : ""}`}
>
{copied ? (
<>
<Check size={16} weight="bold" />
<span>Copied</span>
</>
) : (
<>
<CopySimple size={16} />
<span>Copy All</span>
</>
)}
</button>
)}
</div>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
className="write-textarea"
placeholder="Start writing your markdown..."
spellCheck={true}
autoComplete="off"
autoCapitalize="sentences"
autoFocus
style={{ fontFamily: FONTS[font] }}
/>
{/* Conditionally render textarea or AI chat */}
{isAIChatMode ? (
<div className="write-ai-chat-container">
<AIChatView contextId="write-page" />
</div>
) : (
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
className="write-textarea"
placeholder="Start writing your markdown..."
spellCheck={true}
autoComplete="off"
autoCapitalize="sentences"
style={{ fontFamily: FONTS[font] }}
/>
)}
{/* Footer with stats */}
<div className="write-main-footer">
<div className="write-stats">
<span>{words} words</span>
<span className="write-stats-divider" />
<span>{lines} lines</span>
<span className="write-stats-divider" />
<span>{characters} chars</span>
{/* Footer with stats - only show in text editor mode */}
{!isAIChatMode && (
<div className="write-main-footer">
<div className="write-stats">
<span>{words} words</span>
<span className="write-stats-divider" />
<span>{lines} lines</span>
<span className="write-stats-divider" />
<span>{characters} chars</span>
</div>
<div className="write-save-hint">
Save to{" "}
<code>content/{contentType === "post" ? "blog" : "pages"}/</code>{" "}
then <code>npm run sync</code>
</div>
</div>
<div className="write-save-hint">
Save to{" "}
<code>content/{contentType === "post" ? "blog" : "pages"}/</code>{" "}
then <code>npm run sync</code>
</div>
</div>
)}
</main>
{/* Right Sidebar: Frontmatter fields */}

View File

@@ -318,6 +318,12 @@ body {
/* padding: 40px 24px; */
}
/* Expand main-content to full width when sidebar layout is used */
.main-content:has(.post-page-with-sidebar) {
max-width: 100%;
padding: 0 0px;
}
/* Wide content layout for pages that need more space (stats, etc.) */
.main-content-wide {
flex: 1;
@@ -341,6 +347,8 @@ body {
background-color: var(--bg-primary);
padding: 8px 12px;
border-radius: 8px;
/* nav bar border to match sidebar if sidebar is enabled
border-bottom: 1px solid var(--border-sidebar); */
}
/* Logo in top navigation */
@@ -655,13 +663,11 @@ body {
/* Full-width sidebar layout - breaks out of .main-content constraints */
.post-page-with-sidebar {
padding-top: 0px;
/* Break out of the 680px max-width container */
width: calc(100vw - 48px);
max-width: none;
margin-left: calc(-1 * (min(100vw - 48px, 1400px) - 680px) / 2);
width: 100%;
max-width: 100%;
margin-left: 0;
margin-right: 0;
position: relative;
/* Add left padding to align content with sidebar edge
padding-left: 24px;*/
}
.post-nav {
@@ -892,6 +898,7 @@ body {
@media (min-width: 1135px) {
.post-content-with-sidebar:has(.post-sidebar-right) {
grid-template-columns: 240px 1fr 280px;
max-width: 100%;
}
/* Adjust main content padding when right sidebar exists */
@@ -947,7 +954,7 @@ body {
-ms-overflow-style: none; /* IE */
scrollbar-width: none; /* Firefox */
background-color: var(--bg-sidebar);
margin-right: -24px;
margin-right: 0;
padding-left: 24px;
padding-right: 24px;
padding-top: 24px;
@@ -1104,6 +1111,7 @@ body {
width: 100%;
max-width: 100%;
margin-left: 0;
margin-right: 0;
}
.post-content-with-sidebar {
@@ -4465,7 +4473,9 @@ body {
display: grid;
grid-template-columns: 220px 1fr 280px;
min-height: 100vh;
height: 100vh;
background: var(--bg-primary);
overflow: hidden; /* Prevent any page-level scroll */
}
/* Left Sidebar */
@@ -4474,6 +4484,8 @@ body {
flex-direction: column;
border-right: 1px solid var(--border-color);
background: var(--bg-secondary);
overflow-y: auto;
max-height: 100vh;
}
.write-sidebar-header {
@@ -4577,7 +4589,14 @@ body {
display: flex;
flex-direction: column;
min-height: 100vh;
max-height: 100vh;
background: var(--bg-primary);
overflow: hidden;
}
/* When AI chat is active, ensure proper height constraints */
.write-main:has(.write-ai-chat-container) {
height: 100vh;
}
.write-main-header {
@@ -4699,6 +4718,8 @@ body {
flex-direction: column;
border-left: 1px solid var(--border-color);
background: var(--bg-primary);
overflow-y: auto;
max-height: 100vh;
}
.write-sidebar-right .write-sidebar-header {
@@ -4862,6 +4883,15 @@ body {
.write-main {
min-height: auto;
max-height: none;
overflow: visible;
}
/* On mobile, AI chat gets fixed height */
.write-main:has(.write-ai-chat-container) {
height: calc(100vh - 120px); /* Account for mobile nav */
max-height: calc(100vh - 120px);
overflow: hidden;
}
.write-main-header {
@@ -4932,3 +4962,830 @@ body {
grid-template-columns: 1fr;
}
}
/* AI Chat Styles */
/* Write page AI chat container */
.write-ai-chat-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; /* Allow shrinking in flex container */
overflow: hidden;
}
.write-ai-chat-container .ai-chat-view {
flex: 1;
height: 100%;
min-height: 0; /* Allow shrinking in flex container */
}
.ai-chat-view {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0; /* Allow shrinking in flex container */
background-color: var(--bg-primary);
border-radius: 8px;
overflow: hidden;
}
.ai-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-secondary);
flex-shrink: 0;
}
.ai-chat-title {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-primary);
}
.ai-chat-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.ai-chat-load-context-button {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--text-secondary);
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-chat-load-context-button:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.ai-chat-context-loaded {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--font-size-xs);
color: #10b981;
}
.ai-chat-clear-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
color: var(--text-muted);
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-chat-clear-button:hover:not(:disabled) {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.ai-chat-clear-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ai-chat-close-button {
padding: 6px 12px;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--text-secondary);
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-chat-close-button:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.ai-chat-messages {
flex: 1;
min-height: 0; /* Critical: allows flex item to shrink below content size */
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
scroll-behavior: smooth;
}
.ai-chat-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
color: var(--text-muted);
padding: 32px;
}
.ai-chat-empty p {
margin: 0 0 8px;
}
.ai-chat-empty-hint {
font-size: var(--font-size-xs);
opacity: 0.8;
}
.ai-chat-message {
display: flex;
gap: 8px;
max-width: 85%;
animation: aiChatFadeIn 0.2s ease;
}
@keyframes aiChatFadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ai-chat-message-user {
align-self: flex-end;
flex-direction: row-reverse;
}
.ai-chat-message-assistant {
align-self: flex-start;
}
.ai-chat-message-content {
padding: 10px 14px;
border-radius: 12px;
font-size: var(--font-size-sm);
line-height: 1.5;
word-wrap: break-word;
overflow-wrap: break-word;
}
.ai-chat-message-user .ai-chat-message-content {
background-color: var(--text-primary);
color: var(--bg-primary);
border-bottom-right-radius: 4px;
}
.ai-chat-message-assistant .ai-chat-message-content {
background-color: var(--bg-secondary);
color: var(--text-primary);
border-bottom-left-radius: 4px;
}
.ai-chat-message-content p {
margin: 0 0 8px;
}
.ai-chat-message-content p:last-child {
margin-bottom: 0;
}
.ai-chat-message-content h1,
.ai-chat-message-content h2,
.ai-chat-message-content h3,
.ai-chat-message-content h4,
.ai-chat-message-content h5,
.ai-chat-message-content h6 {
margin: 12px 0 8px;
font-size: var(--font-size-sm);
font-weight: 600;
}
.ai-chat-message-content h1:first-child,
.ai-chat-message-content h2:first-child,
.ai-chat-message-content h3:first-child {
margin-top: 0;
}
.ai-chat-message-content ul,
.ai-chat-message-content ol {
margin: 8px 0;
padding-left: 20px;
}
.ai-chat-message-content li {
margin-bottom: 4px;
}
.ai-chat-message-content code {
font-family:
"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
font-size: 0.9em;
padding: 2px 6px;
background-color: var(--bg-hover);
border-radius: 4px;
}
.ai-chat-message-content pre {
margin: 8px 0;
padding: 12px;
background-color: var(--bg-hover);
border-radius: 6px;
overflow-x: auto;
}
.ai-chat-message-content pre code {
padding: 0;
background: none;
}
.ai-chat-message-content a {
color: var(--link-color);
text-decoration: none;
}
.ai-chat-message-content a:hover {
text-decoration: underline;
}
.ai-chat-message-content blockquote {
margin: 8px 0;
padding-left: 12px;
border-left: 3px solid var(--border-color);
color: var(--text-secondary);
}
.ai-chat-copy-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: var(--text-muted);
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
opacity: 0;
transition: all 0.15s ease;
flex-shrink: 0;
align-self: flex-start;
margin-top: 4px;
}
.ai-chat-message:hover .ai-chat-copy-button {
opacity: 1;
}
.ai-chat-copy-button:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.ai-chat-loading {
display: flex;
align-items: center;
justify-content: space-between;
}
.ai-chat-loading-content {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background-color: var(--bg-secondary);
border-radius: 12px;
border-bottom-left-radius: 4px;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.ai-chat-spinner {
animation: aiChatSpin 1s linear infinite;
}
@keyframes aiChatSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.ai-chat-stop-button {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
font-size: var(--font-size-xs);
font-weight: 500;
color: #ef4444;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-chat-stop-button:hover {
background-color: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
}
.ai-chat-stopped {
font-size: var(--font-size-xs);
color: var(--text-muted);
text-align: center;
padding: 8px;
}
.ai-chat-error {
background-color: transparent;
}
.ai-chat-error .ai-chat-message-content {
color: #ef4444;
}
.ai-chat-input-container {
padding: 12px 16px;
border-top: 1px solid var(--border-color);
background-color: var(--bg-secondary);
flex-shrink: 0;
flex-grow: 0;
}
.ai-chat-input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
transition: border-color 0.15s ease;
}
.ai-chat-input-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.ai-chat-attach-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
color: var(--text-muted);
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-chat-attach-button:hover:not(:disabled) {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.ai-chat-attach-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ai-chat-input-wrapper:focus-within {
border-color: var(--text-muted);
}
.ai-chat-input {
flex: 1;
min-height: 24px;
max-height: 200px;
padding: 0;
font-family: inherit;
font-size: var(--font-size-sm);
line-height: 1.5;
color: var(--text-primary);
background: none;
border: none;
resize: none;
outline: none;
/* Prevent scroll jump when focusing */
scroll-margin: 0;
}
.ai-chat-input::placeholder {
color: var(--text-muted);
}
.ai-chat-send-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
color: var(--bg-primary);
background-color: var(--text-primary);
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
}
.ai-chat-send-button:hover:not(:disabled) {
opacity: 0.85;
}
.ai-chat-send-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Attachments preview */
.ai-chat-attachments-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 16px;
border-top: 1px solid var(--border-color);
background-color: var(--bg-secondary);
flex-shrink: 0;
}
.ai-chat-attachment-preview {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.ai-chat-attachment-preview-image {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.ai-chat-attachment-preview-url {
font-size: var(--font-size-xs);
color: var(--text-secondary);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-chat-attachment-remove {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
color: var(--text-muted);
background-color: var(--bg-hover);
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
}
.ai-chat-attachment-remove:hover {
background-color: var(--text-primary);
color: var(--bg-primary);
}
/* Link input modal */
.ai-chat-link-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: backdropFadeIn 0.2s ease;
}
.ai-chat-link-modal-content {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.ai-chat-link-modal-content h3 {
margin: 0 0 12px;
font-size: var(--font-size-base);
font-weight: 600;
color: var(--text-primary);
}
.ai-chat-link-input {
width: 100%;
padding: 8px 12px;
font-size: var(--font-size-sm);
color: var(--text-primary);
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
margin-bottom: 12px;
outline: none;
transition: border-color 0.15s ease;
}
.ai-chat-link-input:focus {
border-color: var(--text-muted);
}
.ai-chat-link-modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.ai-chat-link-modal-cancel,
.ai-chat-link-modal-add {
padding: 6px 12px;
font-size: var(--font-size-sm);
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid var(--border-color);
}
.ai-chat-link-modal-cancel {
color: var(--text-secondary);
background-color: var(--bg-secondary);
}
.ai-chat-link-modal-cancel:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.ai-chat-link-modal-add {
color: var(--bg-primary);
background-color: var(--text-primary);
border-color: var(--text-primary);
}
.ai-chat-link-modal-add:hover:not(:disabled) {
opacity: 0.85;
}
.ai-chat-link-modal-add:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Attachments in messages */
.ai-chat-attachments {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.ai-chat-attachment {
display: flex;
align-items: center;
gap: 8px;
}
.ai-chat-attachment-image {
max-width: 200px;
max-height: 200px;
border-radius: 6px;
object-fit: contain;
display: block;
}
.ai-chat-attachment-loading {
padding: 8px;
font-size: var(--font-size-xs);
color: var(--text-muted);
font-style: italic;
}
.ai-chat-attachment-link {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
text-decoration: none;
padding: 6px 10px;
background-color: var(--bg-secondary);
border-radius: 6px;
font-size: var(--font-size-xs);
transition: background-color 0.15s ease;
}
.ai-chat-attachment-link:hover {
background-color: var(--bg-hover);
}
/* AI Chat in Write Page - overrides for Write context */
.write-ai-chat-container .ai-chat-view {
border-radius: 0;
}
/* AI Chat toggle button in Write nav */
.write-nav-item.ai-chat-active {
background-color: var(--text-primary);
color: var(--bg-primary);
}
.write-nav-item.ai-chat-active:hover {
background-color: var(--text-primary);
opacity: 0.9;
}
/* AI Chat in Right Sidebar */
.right-sidebar-ai-chat {
height: 100%;
min-height: 300px;
}
.right-sidebar-ai-chat .ai-chat-view {
height: 100%;
border-radius: 0;
}
.right-sidebar-ai-chat .ai-chat-header {
padding: 10px 12px;
}
.right-sidebar-ai-chat .ai-chat-title {
font-size: var(--font-size-xs);
}
.right-sidebar-ai-chat .ai-chat-messages {
padding: 12px;
gap: 10px;
}
.right-sidebar-ai-chat .ai-chat-message {
max-width: 95%;
}
.right-sidebar-ai-chat .ai-chat-message-content {
padding: 8px 12px;
font-size: var(--font-size-xs);
}
.right-sidebar-ai-chat .ai-chat-input-container {
padding: 10px 12px;
}
.right-sidebar-ai-chat .ai-chat-input {
font-size: var(--font-size-xs);
}
.right-sidebar-ai-chat .ai-chat-send-button {
width: 32px;
height: 32px;
}
/* Theme-specific AI Chat styles */
:root[data-theme="dark"] .ai-chat-message-user .ai-chat-message-content {
background-color: #f5f5f5;
color: #171717;
}
:root[data-theme="dark"] .ai-chat-message-content code {
background-color: rgba(255, 255, 255, 0.1);
}
:root[data-theme="dark"] .ai-chat-message-content pre {
background-color: rgba(255, 255, 255, 0.05);
}
:root[data-theme="tan"] .ai-chat-message-user .ai-chat-message-content {
background-color: #1a1a1a;
color: #faf8f5;
}
:root[data-theme="cloud"] .ai-chat-message-user .ai-chat-message-content {
background-color: #1e293b;
color: #f8fafc;
}
/* Mobile responsive AI Chat */
@media (max-width: 768px) {
.ai-chat-header {
padding: 10px 12px;
}
.ai-chat-title {
font-size: var(--font-size-xs);
}
.ai-chat-messages {
padding: 12px;
gap: 10px;
}
.ai-chat-message {
max-width: 90%;
}
.ai-chat-message-content {
padding: 8px 12px;
font-size: var(--font-size-xs);
}
.ai-chat-input-container {
padding: 10px 12px;
}
.ai-chat-load-context-button span {
display: none;
}
.ai-chat-close-button {
padding: 6px 8px;
font-size: 10px;
}
.ai-chat-copy-button {
opacity: 1;
}
.ai-chat-attachments-preview {
padding: 8px 12px;
}
.ai-chat-attachment-preview-image {
width: 50px;
height: 50px;
}
.ai-chat-attachment-preview-url {
max-width: 150px;
}
.ai-chat-link-modal-content {
padding: 16px;
max-width: 90%;
}
.ai-chat-attachment-image {
max-width: 150px;
max-height: 150px;
}
}
@media (max-width: 480px) {
.ai-chat-header-actions {
gap: 4px;
}
.ai-chat-empty {
padding: 20px;
}
.ai-chat-empty p {
font-size: var(--font-size-xs);
}
}