Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 62 additions & 34 deletions components/editor/ai-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
"use client";

import { FormEvent, KeyboardEvent, useMemo, useRef, useState } from "react";
import { Bot, Download, FileText, Send, X } from "lucide-react";
import { FormEvent, KeyboardEvent, useMemo, useState } from "react";
import { Bot, Download, FileText, Loader2, Send, X } from "lucide-react";

import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useDesignAgent } from "@/hooks/use-design-agent";
import { cn } from "@/lib/utils";

interface AiSidebarProps {
open: boolean;
onClose: () => void;
}

interface ChatMessage {
id: string;
role: "user" | "assistant";
text: string;
/** Room the generated design is written into. Also the project access check. */
projectId: string;
}

const STARTER_PROMPTS = [
Expand All @@ -27,27 +24,18 @@ const STARTER_PROMPTS = [
"Build a CI/CD pipeline",
] as const;

export function AiSidebar({ open, onClose }: AiSidebarProps) {
export function AiSidebar({ open, onClose, projectId }: AiSidebarProps) {
const [inputValue, setInputValue] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([]);
const messageCounterRef = useRef(0);
const { messages, statusText, isRunning, sendPrompt } = useDesignAgent(projectId);

const showEmptyState = useMemo(() => messages.length === 0, [messages.length]);

const sendMessage = (text: string) => {
const trimmed = text.trim();
if (!trimmed) {
if (!text.trim() || isRunning) {
return;
}

const nextMessage: ChatMessage = {
id: `user-${messageCounterRef.current}`,
role: "user",
text: trimmed,
};
messageCounterRef.current += 1;

setMessages((prev) => [...prev, nextMessage]);
sendPrompt(text);
setInputValue("");
};

Expand Down Expand Up @@ -108,7 +96,8 @@ export function AiSidebar({ open, onClose }: AiSidebarProps) {
key={prompt}
type="button"
onClick={() => sendMessage(prompt)}
className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground"
disabled={isRunning}
className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
{prompt}
</button>
Expand All @@ -117,19 +106,45 @@ export function AiSidebar({ open, onClose }: AiSidebarProps) {
</div>
) : null}

{messages.map((message) => (
<div key={message.id} className={message.role === "user" ? "flex justify-end" : "flex justify-start"}>
{messages.map((message) => {
if (message.role === "user") {
return (
<div key={message.id} className="flex justify-end">
<div className="max-w-[85%] rounded-md bg-primary px-3 py-2 text-xs text-primary-foreground">
{message.text}
</div>
</div>
);
}

return (
<div key={message.id} className="flex justify-start">
<div
className={cn(
"max-w-[90%] rounded-md px-3 py-2 text-xs",
message.isError
? "border border-[var(--state-error)]/40 bg-[var(--state-error)]/10 text-[var(--state-error)]"
: "bg-muted text-foreground",
)}
role={message.isError ? "alert" : undefined}
>
{message.text}
</div>
</div>
);
})}

{statusText ? (
<div className="flex justify-start">
<div
className={
message.role === "user"
? "max-w-[85%] rounded-md bg-primary px-3 py-2 text-xs text-primary-foreground"
: "max-w-[90%] rounded-md bg-muted px-3 py-2 text-xs text-foreground"
}
className="flex max-w-[90%] items-center gap-2 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground"
aria-live="polite"
>
{message.text}
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{statusText}
</div>
</div>
))}
) : null}
</div>
</ScrollArea>

Expand All @@ -139,12 +154,25 @@ export function AiSidebar({ open, onClose }: AiSidebarProps) {
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
onKeyDown={handleKeyDown}
disabled={isRunning}
className="max-h-36 min-h-24 resize-none bg-background pr-14 text-xs"
/>
<div className="mt-2 flex items-center justify-between">
<p className="text-[11px] text-muted-foreground">Enter to send, Shift+Enter for a new line</p>
<Button type="submit" size="icon" className="h-8 w-8 rounded-full" aria-label="Send message">
<Send className="h-4 w-4" />
<p className="text-[11px] text-muted-foreground">
{isRunning ? "Generating your design..." : "Enter to send, Shift+Enter for a new line"}
</p>
<Button
type="submit"
size="icon"
className="h-8 w-8 rounded-full"
disabled={isRunning || inputValue.trim().length === 0}
aria-label="Send message"
>
{isRunning ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</form>
Expand Down
7 changes: 6 additions & 1 deletion components/editor/canvas-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import { useUndo, useRedo, useUpdateMyPresence } from "@liveblocks/react";

import { CanvasNodeComponent } from "@/components/editor/canvas-node";
import { CanvasEdgeComponent, CanvasEdgeMarkerDefs } from "@/components/editor/canvas-edge";
import { CanvasPresenceOverlay, LiveCursors } from "@/components/editor/canvas-presence";
import {
CanvasPresenceOverlay,
CanvasThinkingIndicator,
LiveCursors,
} from "@/components/editor/canvas-presence";
import { ShapePanel, SHAPE_DRAG_MIME, type ShapeDragPayload } from "@/components/editor/shape-panel";
import { CanvasControlBar } from "@/components/editor/canvas-control-bar";
import { StarterTemplatesModal } from "@/components/editor/starter-templates-modal";
Expand Down Expand Up @@ -359,6 +363,7 @@ function CanvasFlowInner({

{/* Floating overlays */}
<CanvasPresenceOverlay />
<CanvasThinkingIndicator />
<CanvasControlBar
onOpenTemplates={() => setIsTemplatesOpen(true)}
isSidebarOpen={isSidebarOpen}
Expand Down
32 changes: 32 additions & 0 deletions components/editor/canvas-presence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useMemo } from "react";
import { useUser, UserButton } from "@clerk/nextjs";
import { useOthers } from "@liveblocks/react/suspense";
import { ViewportPortal } from "@xyflow/react";
import { Bot } from "lucide-react";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";

Expand Down Expand Up @@ -71,6 +72,37 @@ export function CanvasPresenceOverlay() {
);
}

/**
* Shows that a collaborator has a design generation running in this room,
* driven by the `thinking` presence flag the AI sidebar sets.
*
* Rendered top-center so it stays visible when the AI sidebar is open.
*/
export function CanvasThinkingIndicator() {
const others = useOthers();

const thinkingCount = useMemo(() => {
return others.filter((other) => other.presence.thinking).length;
}, [others]);

if (thinkingCount === 0) {
return null;
}

return (
<div className="pointer-events-none absolute left-1/2 top-3 z-20 -translate-x-1/2">
<div className="flex items-center gap-2 rounded-md border border-[var(--border-default)] bg-[var(--bg-surface)]/90 px-2.5 py-1.5 backdrop-blur-sm">
<Bot className="h-4 w-4 animate-pulse text-[var(--accent-primary)]" aria-hidden="true" />
<p className="text-xs text-[var(--text-muted)]" aria-live="polite">
{thinkingCount === 1
? "A collaborator is generating a design…"
: `${thinkingCount} collaborators are generating designs…`}
</p>
</div>
</div>
);
}

export function LiveCursors() {
const { user } = useUser();
const others = useOthers();
Expand Down
9 changes: 9 additions & 0 deletions components/editor/canvas-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ interface CanvasWrapperProps {
canAutosave: boolean;
onSaveStatusChange?: (status: CanvasSaveStatus) => void;
isSidebarOpen: boolean;
/**
* Rendered inside the room, next to the canvas. Overlays that need room
* presence (such as the AI sidebar) belong here rather than as a sibling of
* `CanvasWrapper`, which would place them outside the `RoomProvider`.
*/
children?: ReactNode;
}

/**
Expand All @@ -58,12 +64,14 @@ interface CanvasWrapperProps {
* - Initial presence includes cursor: null (no active cursor on join)
* - ClientSideSuspense defers rendering until the room is ready
* - CanvasErrorBoundary catches Liveblocks connection failures
* - `children` render inside the room so they can read and write presence
*/
export function CanvasWrapper({
roomId,
canAutosave,
onSaveStatusChange,
isSidebarOpen,
children,
}: CanvasWrapperProps) {
return (
<LiveblocksProvider authEndpoint="/api/liveblocks-auth">
Expand All @@ -87,6 +95,7 @@ export function CanvasWrapper({
/>
</ClientSideSuspense>
</CanvasErrorBoundary>
{children}
</RoomProvider>
</LiveblocksProvider>
);
Expand Down
9 changes: 7 additions & 2 deletions components/editor/editor-workspace-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,13 @@ export function EditorWorkspaceShell({
canAutosave={isOwner}
onSaveStatusChange={setSaveStatus}
isSidebarOpen={isSidebarOpen}
/>
<AiSidebar open={isAiSidebarOpen} onClose={() => setIsAiSidebarOpen(false)} />
>
<AiSidebar
open={isAiSidebarOpen}
onClose={() => setIsAiSidebarOpen(false)}
projectId={projectId}
/>
</CanvasWrapper>
</section>
</main>

Expand Down
Loading
Loading