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
4 changes: 3 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@
{
"mode": "auto"
}
]
],
"git.detectSubmodules": false,
"git.autoRepositoryDetection": "openEditors"
}
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Core models: `agents`, `tasks`, `messages` (see `packages/backend/convex/schema.
- Prefer reusable components and functions over duplication
- Keep it simple - avoid over-engineering and premature abstractions
- **Use strong typing** - leverage types from external packages; avoid `any` and type assertions
- **No type suppression** - avoid `@ts-expect-error`, `@ts-ignore`, and `as` casts; fix types properly instead
- Prefer named exports over default exports (except Next.js pages/layouts where required)
- Use `@clawe/ui/components/*` for UI imports
- Use `@/` alias for app-local imports
Expand Down
9 changes: 8 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -34,22 +33,30 @@
"@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",
"next-themes": "^0.4.6",
"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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -47,6 +47,11 @@ const SidebarNavContent = () => {
url: "/agents",
icon: Bot,
},
{
title: "Chat",
url: "/chat",
icon: MessageSquare,
},
{
title: "Settings",
url: "/settings",
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/app/(dashboard)/chat/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <Chat sessionKey={SESSION_KEY} mode="full" className="h-full" />;
}
7 changes: 4 additions & 3 deletions apps/web/src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -53,7 +54,7 @@ const DashboardLayout = ({ children, header }: DashboardLayoutProps) => {
{header}
</header>
{fullHeight ? (
<main className="flex min-h-0 flex-1 flex-col p-6">{children}</main>
<main className="flex min-h-0 flex-1 flex-col">{children}</main>
) : (
<ScrollArea className="h-full min-h-0 flex-1">
<main className="p-6">{children}</main>
Expand Down
112 changes: 112 additions & 0 deletions apps/web/src/app/api/chat/abort/route.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
52 changes: 52 additions & 0 deletions apps/web/src/app/api/chat/abort/route.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
103 changes: 103 additions & 0 deletions apps/web/src/app/api/chat/history/route.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading