From e4ed24e88e42a980a196ffd8bd55337b82d40838 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sat, 7 Feb 2026 13:31:53 +0200 Subject: [PATCH 1/3] add chat capability --- .vscode/settings.json | 4 +- apps/web/package.json | 9 +- .../_components/dashboard-sidebar.tsx | 7 +- apps/web/src/app/(dashboard)/chat/page.tsx | 8 + apps/web/src/app/(dashboard)/layout.tsx | 7 +- apps/web/src/app/api/chat/abort/route.spec.ts | 112 +++ apps/web/src/app/api/chat/abort/route.ts | 52 + .../src/app/api/chat/history/route.spec.ts | 103 ++ apps/web/src/app/api/chat/history/route.ts | 59 ++ apps/web/src/app/api/chat/route.spec.ts | 78 ++ apps/web/src/app/api/chat/route.ts | 159 +++ apps/web/src/components/.gitkeep | 0 .../src/components/chat/chat-attachments.tsx | 68 ++ apps/web/src/components/chat/chat-empty.tsx | 32 + .../src/components/chat/chat-header.spec.tsx | 41 + apps/web/src/components/chat/chat-header.tsx | 64 ++ .../components/chat/chat-input-textarea.tsx | 68 ++ .../src/components/chat/chat-input.spec.tsx | 102 ++ apps/web/src/components/chat/chat-input.tsx | 213 ++++ .../components/chat/chat-message-content.tsx | 161 +++ .../src/components/chat/chat-message.spec.tsx | 78 ++ apps/web/src/components/chat/chat-message.tsx | 72 ++ .../components/chat/chat-messages.spec.tsx | 67 ++ .../web/src/components/chat/chat-messages.tsx | 117 +++ .../chat/chat-scroll-button.spec.tsx | 37 + .../components/chat/chat-scroll-button.tsx | 35 + .../web/src/components/chat/chat-thinking.tsx | 49 + .../components/chat/chat-tool-event.spec.tsx | 66 ++ .../src/components/chat/chat-tool-event.tsx | 94 ++ apps/web/src/components/chat/chat.spec.tsx | 67 ++ apps/web/src/components/chat/chat.tsx | 104 ++ apps/web/src/components/chat/index.ts | 14 + apps/web/src/components/chat/types.ts | 124 +++ apps/web/src/hooks/use-auto-scroll.spec.ts | 91 ++ apps/web/src/hooks/use-auto-scroll.ts | 151 +++ apps/web/src/hooks/use-chat.spec.ts | 174 ++++ apps/web/src/hooks/use-chat.ts | 508 ++++++++++ .../src/lib/openclaw/gateway-client.spec.ts | 94 ++ apps/web/src/lib/openclaw/gateway-client.ts | 258 +++++ apps/web/src/lib/openclaw/gateway-types.ts | 195 ++++ apps/web/vitest.config.ts | 5 +- apps/web/vitest.setup.ts | 1 + pnpm-lock.yaml | 916 +++++++++++++++++- 43 files changed, 4651 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/chat/page.tsx create mode 100644 apps/web/src/app/api/chat/abort/route.spec.ts create mode 100644 apps/web/src/app/api/chat/abort/route.ts create mode 100644 apps/web/src/app/api/chat/history/route.spec.ts create mode 100644 apps/web/src/app/api/chat/history/route.ts create mode 100644 apps/web/src/app/api/chat/route.spec.ts create mode 100644 apps/web/src/app/api/chat/route.ts delete mode 100644 apps/web/src/components/.gitkeep create mode 100644 apps/web/src/components/chat/chat-attachments.tsx create mode 100644 apps/web/src/components/chat/chat-empty.tsx create mode 100644 apps/web/src/components/chat/chat-header.spec.tsx create mode 100644 apps/web/src/components/chat/chat-header.tsx create mode 100644 apps/web/src/components/chat/chat-input-textarea.tsx create mode 100644 apps/web/src/components/chat/chat-input.spec.tsx create mode 100644 apps/web/src/components/chat/chat-input.tsx create mode 100644 apps/web/src/components/chat/chat-message-content.tsx create mode 100644 apps/web/src/components/chat/chat-message.spec.tsx create mode 100644 apps/web/src/components/chat/chat-message.tsx create mode 100644 apps/web/src/components/chat/chat-messages.spec.tsx create mode 100644 apps/web/src/components/chat/chat-messages.tsx create mode 100644 apps/web/src/components/chat/chat-scroll-button.spec.tsx create mode 100644 apps/web/src/components/chat/chat-scroll-button.tsx create mode 100644 apps/web/src/components/chat/chat-thinking.tsx create mode 100644 apps/web/src/components/chat/chat-tool-event.spec.tsx create mode 100644 apps/web/src/components/chat/chat-tool-event.tsx create mode 100644 apps/web/src/components/chat/chat.spec.tsx create mode 100644 apps/web/src/components/chat/chat.tsx create mode 100644 apps/web/src/components/chat/index.ts create mode 100644 apps/web/src/components/chat/types.ts create mode 100644 apps/web/src/hooks/use-auto-scroll.spec.ts create mode 100644 apps/web/src/hooks/use-auto-scroll.ts create mode 100644 apps/web/src/hooks/use-chat.spec.ts create mode 100644 apps/web/src/hooks/use-chat.ts create mode 100644 apps/web/src/lib/openclaw/gateway-client.spec.ts create mode 100644 apps/web/src/lib/openclaw/gateway-client.ts create mode 100644 apps/web/src/lib/openclaw/gateway-types.ts create mode 100644 apps/web/vitest.setup.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 44a73ec..318fe07 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,7 @@ { "mode": "auto" } - ] + ], + "git.detectSubmodules": false, + "git.autoRepositoryDetection": "openEditors" } diff --git a/apps/web/package.json b/apps/web/package.json index b50d5d8..92fd709 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,7 +19,6 @@ "dependencies": { "@clawe/backend": "workspace:*", "@clawe/ui": "workspace:*", - "convex": "^1.21.0", "@tanstack/react-query": "^5.75.5", "@tiptap/core": "^3.15.3", "@tiptap/extension-bubble-menu": "^3.15.3", @@ -34,6 +33,7 @@ "@tiptap/suggestion": "^3.15.3", "@xyflow/react": "^12.10.0", "axios": "^1.13.4", + "convex": "^1.21.0", "framer-motion": "^12.29.0", "lucide-react": "^0.562.0", "next": "16.1.0", @@ -41,15 +41,22 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "tippy.js": "^6.3.7", + "ws": "^8.19.0", "zod": "^4.3.6" }, "devDependencies": { "@clawe/eslint-config": "workspace:*", "@clawe/typescript-config": "workspace:*", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/jsdom": "^27.0.0", "@types/node": "^22.15.3", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", + "@types/ws": "^8.18.1", + "@vitejs/plugin-react": "^5.1.3", "eslint": "^9.39.1", + "jsdom": "^28.0.0", "typescript": "5.9.2", "vitest": "^4.0.18" } diff --git a/apps/web/src/app/(dashboard)/_components/dashboard-sidebar.tsx b/apps/web/src/app/(dashboard)/_components/dashboard-sidebar.tsx index bdfef53..cfdcdba 100644 --- a/apps/web/src/app/(dashboard)/_components/dashboard-sidebar.tsx +++ b/apps/web/src/app/(dashboard)/_components/dashboard-sidebar.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { useRouter, usePathname } from "next/navigation"; -import { SquareKanban, Bot, Settings } from "lucide-react"; +import { SquareKanban, Bot, Settings, MessageSquare } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; import { NavMain, type NavItem } from "./nav-main"; @@ -47,6 +47,11 @@ const SidebarNavContent = () => { url: "/agents", icon: Bot, }, + { + title: "Chat", + url: "/chat", + icon: MessageSquare, + }, { title: "Settings", url: "/settings", diff --git a/apps/web/src/app/(dashboard)/chat/page.tsx b/apps/web/src/app/(dashboard)/chat/page.tsx new file mode 100644 index 0000000..80c24c5 --- /dev/null +++ b/apps/web/src/app/(dashboard)/chat/page.tsx @@ -0,0 +1,8 @@ +import { Chat } from "@/components/chat"; + +// Use the full agent session key format for consistency with OpenClaw +const SESSION_KEY = "agent:main:main"; + +export default function ChatPage() { + return ; +} diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 306f70b..d08c2d2 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -14,8 +14,9 @@ type DashboardLayoutProps = { header: React.ReactNode; }; -// Routes that handle their own scrolling (e.g., kanban board) -const isFullHeightRoute = (path: string) => path === "/board"; +// Routes that handle their own scrolling (e.g., kanban board, chat) +const isFullHeightRoute = (path: string) => + path === "/board" || path === "/chat"; const DashboardLayout = ({ children, header }: DashboardLayoutProps) => { const pathname = usePathname(); @@ -53,7 +54,7 @@ const DashboardLayout = ({ children, header }: DashboardLayoutProps) => { {header} {fullHeight ? ( -
{children}
+
{children}
) : (
{children}
diff --git a/apps/web/src/app/api/chat/abort/route.spec.ts b/apps/web/src/app/api/chat/abort/route.spec.ts new file mode 100644 index 0000000..e061a2b --- /dev/null +++ b/apps/web/src/app/api/chat/abort/route.spec.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { POST } from "./route"; + +// Mock the gateway client +const mockRequest = vi.fn(); +const mockConnect = vi.fn(); +const mockClose = vi.fn(); + +vi.mock("@/lib/openclaw/gateway-client", () => ({ + createGatewayClient: vi.fn(() => ({ + connect: mockConnect, + request: mockRequest, + close: mockClose, + isConnected: vi.fn().mockReturnValue(true), + })), +})); + +describe("POST /api/chat/abort", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConnect.mockResolvedValue({ type: "hello-ok", protocol: 3 }); + mockRequest.mockResolvedValue({}); + }); + + it("returns 400 when sessionKey is missing", async () => { + const request = new NextRequest("http://localhost/api/chat/abort", { + method: "POST", + body: JSON.stringify({}), + }); + + const response = await POST(request); + expect(response.status).toBe(400); + + const data = await response.json(); + expect(data.error).toBe("sessionKey is required"); + }); + + it("returns 400 for invalid JSON", async () => { + const request = new NextRequest("http://localhost/api/chat/abort", { + method: "POST", + body: "invalid json", + }); + + const response = await POST(request); + expect(response.status).toBe(400); + + const data = await response.json(); + expect(data.error).toBe("Invalid JSON body"); + }); + + it("aborts without runId", async () => { + const request = new NextRequest("http://localhost/api/chat/abort", { + method: "POST", + body: JSON.stringify({ sessionKey: "test-session" }), + }); + + const response = await POST(request); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.success).toBe(true); + + expect(mockRequest).toHaveBeenCalledWith("chat.abort", { + sessionKey: "test-session", + }); + }); + + it("aborts with runId", async () => { + const request = new NextRequest("http://localhost/api/chat/abort", { + method: "POST", + body: JSON.stringify({ + sessionKey: "test-session", + runId: "run-123", + }), + }); + + const response = await POST(request); + expect(response.status).toBe(200); + + expect(mockRequest).toHaveBeenCalledWith("chat.abort", { + sessionKey: "test-session", + runId: "run-123", + }); + }); + + it("returns 500 on gateway error", async () => { + mockRequest.mockRejectedValue(new Error("Abort failed")); + + const request = new NextRequest("http://localhost/api/chat/abort", { + method: "POST", + body: JSON.stringify({ sessionKey: "test-session" }), + }); + + const response = await POST(request); + expect(response.status).toBe(500); + + const data = await response.json(); + expect(data.error).toBe("Abort failed"); + }); + + it("closes client after request", async () => { + const request = new NextRequest("http://localhost/api/chat/abort", { + method: "POST", + body: JSON.stringify({ sessionKey: "test-session" }), + }); + + await POST(request); + + expect(mockClose).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/chat/abort/route.ts b/apps/web/src/app/api/chat/abort/route.ts new file mode 100644 index 0000000..480bdb3 --- /dev/null +++ b/apps/web/src/app/api/chat/abort/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createGatewayClient } from "@/lib/openclaw/gateway-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type AbortRequestBody = { + sessionKey: string; + runId?: string; +}; + +/** + * POST /api/chat/abort + * Abort an in-progress chat generation. + */ +export async function POST(request: NextRequest) { + let body: AbortRequestBody; + + try { + body = (await request.json()) as AbortRequestBody; + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { sessionKey, runId } = body; + + if (!sessionKey || typeof sessionKey !== "string") { + return NextResponse.json( + { error: "sessionKey is required" }, + { status: 400 }, + ); + } + + const client = createGatewayClient(); + + try { + await client.connect(); + + await client.request("chat.abort", { + sessionKey, + ...(runId && { runId }), + }); + + return NextResponse.json({ success: true }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } finally { + client.close(); + } +} diff --git a/apps/web/src/app/api/chat/history/route.spec.ts b/apps/web/src/app/api/chat/history/route.spec.ts new file mode 100644 index 0000000..5af1572 --- /dev/null +++ b/apps/web/src/app/api/chat/history/route.spec.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { GET } from "./route"; + +// Mock the gateway client +const mockRequest = vi.fn(); +const mockConnect = vi.fn(); +const mockClose = vi.fn(); + +vi.mock("@/lib/openclaw/gateway-client", () => ({ + createGatewayClient: vi.fn(() => ({ + connect: mockConnect, + request: mockRequest, + close: mockClose, + isConnected: vi.fn().mockReturnValue(true), + })), +})); + +describe("GET /api/chat/history", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConnect.mockResolvedValue({ type: "hello-ok", protocol: 3 }); + mockRequest.mockResolvedValue({ + messages: [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { role: "assistant", content: [{ type: "text", text: "Hi there!" }] }, + ], + thinkingLevel: "normal", + }); + }); + + it("returns 400 when sessionKey is missing", async () => { + const request = new NextRequest("http://localhost/api/chat/history"); + + const response = await GET(request); + expect(response.status).toBe(400); + + const data = await response.json(); + expect(data.error).toBe("sessionKey query parameter is required"); + }); + + it("returns messages for valid sessionKey", async () => { + const request = new NextRequest( + "http://localhost/api/chat/history?sessionKey=test-session", + ); + + const response = await GET(request); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.messages).toHaveLength(2); + expect(data.thinkingLevel).toBe("normal"); + }); + + it("uses custom limit when provided", async () => { + const request = new NextRequest( + "http://localhost/api/chat/history?sessionKey=test-session&limit=50", + ); + + await GET(request); + + expect(mockRequest).toHaveBeenCalledWith("chat.history", { + sessionKey: "test-session", + limit: 50, + }); + }); + + it("returns 400 for invalid limit", async () => { + const request = new NextRequest( + "http://localhost/api/chat/history?sessionKey=test-session&limit=9999", + ); + + const response = await GET(request); + expect(response.status).toBe(400); + + const data = await response.json(); + expect(data.error).toBe("limit must be between 1 and 1000"); + }); + + it("returns 500 on gateway error", async () => { + mockConnect.mockRejectedValue(new Error("Connection failed")); + + const request = new NextRequest( + "http://localhost/api/chat/history?sessionKey=test-session", + ); + + const response = await GET(request); + expect(response.status).toBe(500); + + const data = await response.json(); + expect(data.error).toBe("Connection failed"); + }); + + it("closes client after request", async () => { + const request = new NextRequest( + "http://localhost/api/chat/history?sessionKey=test-session", + ); + + await GET(request); + + expect(mockClose).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/chat/history/route.ts b/apps/web/src/app/api/chat/history/route.ts new file mode 100644 index 0000000..4cb2443 --- /dev/null +++ b/apps/web/src/app/api/chat/history/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createGatewayClient } from "@/lib/openclaw/gateway-client"; +import type { ChatHistoryResponse } from "@/lib/openclaw/gateway-types"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/chat/history?sessionKey=xxx&limit=200 + */ +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const sessionKey = searchParams.get("sessionKey"); + const limitParam = searchParams.get("limit"); + const limit = limitParam ? parseInt(limitParam, 10) : 200; + + if (!sessionKey) { + return NextResponse.json( + { error: "sessionKey query parameter is required" }, + { status: 400 }, + ); + } + + if (isNaN(limit) || limit < 1 || limit > 1000) { + return NextResponse.json( + { error: "limit must be between 1 and 1000" }, + { status: 400 }, + ); + } + + const client = createGatewayClient(); + + try { + await client.connect(); + + const response = await client.request("chat.history", { + sessionKey, + limit, + }); + + return NextResponse.json( + { + messages: response.messages ?? [], + thinkingLevel: response.thinkingLevel ?? null, + }, + { + headers: { + "Cache-Control": "no-store, no-cache, must-revalidate", + }, + }, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } finally { + client.close(); + } +} diff --git a/apps/web/src/app/api/chat/route.spec.ts b/apps/web/src/app/api/chat/route.spec.ts new file mode 100644 index 0000000..ad55d81 --- /dev/null +++ b/apps/web/src/app/api/chat/route.spec.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { POST } from "./route"; + +// Mock the gateway client +vi.mock("@/lib/openclaw/gateway-client", () => ({ + createGatewayClient: vi.fn(() => ({ + connect: vi.fn().mockResolvedValue({ type: "hello-ok", protocol: 3 }), + request: vi.fn().mockResolvedValue({}), + close: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + })), +})); + +describe("POST /api/chat", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when sessionKey is missing", async () => { + const request = new NextRequest("http://localhost/api/chat", { + method: "POST", + body: JSON.stringify({ message: "Hello" }), + }); + + const response = await POST(request); + expect(response.status).toBe(400); + + const data = await response.json(); + expect(data.error).toBe("sessionKey is required"); + }); + + it("returns SSE stream with correct headers", async () => { + const request = new NextRequest("http://localhost/api/chat", { + method: "POST", + body: JSON.stringify({ + sessionKey: "test-session", + message: "Hello", + }), + }); + + const response = await POST(request); + + expect(response.headers.get("Content-Type")).toBe("text/event-stream"); + expect(response.headers.get("Cache-Control")).toBe("no-cache"); + expect(response.headers.get("Connection")).toBe("keep-alive"); + }); + + it("handles attachments in request body", async () => { + const request = new NextRequest("http://localhost/api/chat", { + method: "POST", + body: JSON.stringify({ + sessionKey: "test-session", + message: "Check this image", + attachments: [ + { + type: "image", + mimeType: "image/png", + content: "base64data", + }, + ], + }), + }); + + const response = await POST(request); + expect(response.status).toBe(200); + }); + + it("returns 500 on invalid JSON", async () => { + const request = new NextRequest("http://localhost/api/chat", { + method: "POST", + body: "invalid json", + }); + + const response = await POST(request); + expect(response.status).toBe(500); + }); +}); diff --git a/apps/web/src/app/api/chat/route.ts b/apps/web/src/app/api/chat/route.ts new file mode 100644 index 0000000..32bf617 --- /dev/null +++ b/apps/web/src/app/api/chat/route.ts @@ -0,0 +1,159 @@ +import { NextRequest } from "next/server"; +import { + GatewayClient, + createGatewayClient, +} from "@/lib/openclaw/gateway-client"; +import type { + ChatEvent, + ChatSendParams, + ChatAttachment, +} from "@/lib/openclaw/gateway-types"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type ChatRequestBody = { + sessionKey: string; + message: string; + attachments?: Array<{ + type: "image"; + mimeType: string; + content: string; + }>; +}; + +/** + * POST /api/chat + * Send a chat message and stream the response via SSE. + */ +export async function POST(request: NextRequest) { + const encoder = new TextEncoder(); + + try { + const body = (await request.json()) as ChatRequestBody; + const { sessionKey, message, attachments } = body; + + if (!sessionKey || typeof sessionKey !== "string") { + return new Response(JSON.stringify({ error: "sessionKey is required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + // Create SSE stream + const stream = new ReadableStream({ + async start(controller) { + const sendEvent = (event: string, data: unknown) => { + const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + controller.enqueue(encoder.encode(payload)); + }; + + let client: GatewayClient | null = null; + + try { + // Create gateway client with chat event handler + client = createGatewayClient({ + onChatEvent: (chatEvent: ChatEvent) => { + if (chatEvent.sessionKey !== sessionKey) { + return; + } + + switch (chatEvent.state) { + case "delta": + sendEvent("delta", { + runId: chatEvent.runId, + message: chatEvent.message, + seq: chatEvent.seq, + }); + break; + case "final": + sendEvent("final", { + runId: chatEvent.runId, + message: chatEvent.message, + usage: chatEvent.usage, + stopReason: chatEvent.stopReason, + }); + break; + case "aborted": + sendEvent("aborted", { + runId: chatEvent.runId, + }); + break; + case "error": + sendEvent("error", { + runId: chatEvent.runId, + message: chatEvent.errorMessage, + }); + break; + } + }, + onClose: () => { + controller.close(); + }, + onError: (error) => { + sendEvent("error", { message: error.message }); + controller.close(); + }, + }); + + // Connect to gateway + await client.connect(); + sendEvent("connected", { sessionKey }); + + // Generate idempotency key + const idempotencyKey = `chat_${Date.now()}_${Math.random().toString(36).slice(2)}`; + + // Prepare attachments + const apiAttachments: ChatAttachment[] | undefined = attachments?.map( + (att) => ({ + type: "image" as const, + mimeType: att.mimeType, + content: att.content, + }), + ); + + // Send chat message + const params: ChatSendParams = { + sessionKey, + message, + deliver: false, + idempotencyKey, + attachments: apiAttachments, + }; + + await client.request("chat.send", params); + + // Keep connection open for events + // The stream will close when final/error/aborted event is received + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + sendEvent("error", { message: errorMessage }); + client?.close(); + controller.close(); + } + + // Handle client disconnect + request.signal.addEventListener("abort", () => { + client?.close(); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return new Response(JSON.stringify({ error: errorMessage }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +} diff --git a/apps/web/src/components/.gitkeep b/apps/web/src/components/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/web/src/components/chat/chat-attachments.tsx b/apps/web/src/components/chat/chat-attachments.tsx new file mode 100644 index 0000000..d1a46ee --- /dev/null +++ b/apps/web/src/components/chat/chat-attachments.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { X } from "lucide-react"; +import { cn } from "@clawe/ui/lib/utils"; +import { Button } from "@clawe/ui/components/button"; +import type { ChatAttachment } from "./types"; + +export type ChatAttachmentsProps = { + attachments: ChatAttachment[]; + onRemove?: (id: string) => void; + className?: string; +}; + +export const ChatAttachments = ({ + attachments, + onRemove, + className, +}: ChatAttachmentsProps) => { + if (attachments.length === 0) { + return null; + } + + return ( +
+ {attachments.map((attachment) => ( + + ))} +
+ ); +}; + +type AttachmentPreviewProps = { + attachment: ChatAttachment; + onRemove?: (id: string) => void; +}; + +const AttachmentPreview = ({ + attachment, + onRemove, +}: AttachmentPreviewProps) => { + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {attachment.name} +
+ + {onRemove && ( + + )} +
+ ); +}; diff --git a/apps/web/src/components/chat/chat-empty.tsx b/apps/web/src/components/chat/chat-empty.tsx new file mode 100644 index 0000000..c8d7062 --- /dev/null +++ b/apps/web/src/components/chat/chat-empty.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { MessageSquare } from "lucide-react"; +import { cn } from "@clawe/ui/lib/utils"; + +export type ChatEmptyProps = { + className?: string; +}; + +export const ChatEmpty = ({ className }: ChatEmptyProps) => { + return ( +
+
+ +
+ +

+ Start a conversation +

+ +

+ Send a message to begin chatting with the AI assistant. You can also + attach images by clicking the paperclip icon. +

+
+ ); +}; diff --git a/apps/web/src/components/chat/chat-header.spec.tsx b/apps/web/src/components/chat/chat-header.spec.tsx new file mode 100644 index 0000000..5e4bef5 --- /dev/null +++ b/apps/web/src/components/chat/chat-header.spec.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ChatHeader } from "./chat-header"; + +describe("ChatHeader", () => { + it("renders title", () => { + render(); + expect(screen.getByText("Chat")).toBeInTheDocument(); + }); + + it("shows generating indicator when streaming", () => { + render(); + expect(screen.getByText("Generating...")).toBeInTheDocument(); + }); + + it("shows close button in panel mode", () => { + const onClose = vi.fn(); + render(); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + }); + + it("hides close button in full mode", () => { + const onClose = vi.fn(); + render(); + + const buttons = screen.queryAllByRole("button"); + expect(buttons).toHaveLength(0); + }); + + it("calls onClose when close button clicked", () => { + const onClose = vi.fn(); + render(); + + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/chat-header.tsx b/apps/web/src/components/chat/chat-header.tsx new file mode 100644 index 0000000..04166c7 --- /dev/null +++ b/apps/web/src/components/chat/chat-header.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { X } from "lucide-react"; +import { cn } from "@clawe/ui/lib/utils"; +import { Button } from "@clawe/ui/components/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@clawe/ui/components/tooltip"; + +export type ChatHeaderProps = { + mode?: "panel" | "full"; + onClose?: () => void; + isStreaming?: boolean; + className?: string; +}; + +export const ChatHeader = ({ + mode = "full", + onClose, + isStreaming, + className, +}: ChatHeaderProps) => { + return ( +
+
+

+ Chat +

+ {isStreaming && ( + Generating... + )} +
+ + {mode === "panel" && onClose && ( + + + + + Close + + )} +
+ ); +}; diff --git a/apps/web/src/components/chat/chat-input-textarea.tsx b/apps/web/src/components/chat/chat-input-textarea.tsx new file mode 100644 index 0000000..526e66c --- /dev/null +++ b/apps/web/src/components/chat/chat-input-textarea.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useRef, useEffect } from "react"; +import { cn } from "@clawe/ui/lib/utils"; + +export type ChatInputTextareaProps = { + value: string; + onChange: (value: string) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + placeholder?: string; + disabled?: boolean; + className?: string; + minRows?: number; + maxRows?: number; +}; + +export const ChatInputTextarea = ({ + value, + onChange, + onKeyDown, + placeholder, + disabled, + className, + minRows = 1, + maxRows = 5, +}: ChatInputTextareaProps) => { + const textareaRef = useRef(null); + + // Auto-resize textarea based on content + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + // Reset height to auto to get the correct scrollHeight + textarea.style.height = "auto"; + + // Calculate line height (approximately 24px for text-sm with leading-relaxed) + const lineHeight = 24; + const minHeight = lineHeight * minRows; + const maxHeight = lineHeight * maxRows; + + // Set the height based on content, clamped between min and max + const newHeight = Math.min( + Math.max(textarea.scrollHeight, minHeight), + maxHeight, + ); + textarea.style.height = `${newHeight}px`; + }, [value, minRows, maxRows]); + + return ( +