Merge branch 'develop' into fix/306-test-failures
This commit is contained in:
@@ -5,6 +5,7 @@ import { BullModule } from "@nestjs/bullmq";
|
|||||||
import { ThrottlerValkeyStorageService, ThrottlerApiKeyGuard } from "./common/throttler";
|
import { ThrottlerValkeyStorageService, ThrottlerApiKeyGuard } from "./common/throttler";
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
import { AppService } from "./app.service";
|
import { AppService } from "./app.service";
|
||||||
|
import { CsrfController } from "./common/controllers/csrf.controller";
|
||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
import { DatabaseModule } from "./database/database.module";
|
import { DatabaseModule } from "./database/database.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
@@ -87,7 +88,7 @@ import { FederationModule } from "./federation/federation.module";
|
|||||||
CoordinatorIntegrationModule,
|
CoordinatorIntegrationModule,
|
||||||
FederationModule,
|
FederationModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
AppService,
|
AppService,
|
||||||
{
|
{
|
||||||
|
|||||||
115
apps/api/src/common/controllers/csrf.controller.spec.ts
Normal file
115
apps/api/src/common/controllers/csrf.controller.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* CSRF Controller Tests
|
||||||
|
*
|
||||||
|
* Tests CSRF token generation endpoint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { CsrfController } from "./csrf.controller";
|
||||||
|
import { Response } from "express";
|
||||||
|
|
||||||
|
describe("CsrfController", () => {
|
||||||
|
let controller: CsrfController;
|
||||||
|
|
||||||
|
controller = new CsrfController();
|
||||||
|
|
||||||
|
describe("getCsrfToken", () => {
|
||||||
|
it("should generate and return a CSRF token", () => {
|
||||||
|
const mockResponse = {
|
||||||
|
cookie: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
const result = controller.getCsrfToken(mockResponse);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("token");
|
||||||
|
expect(typeof result.token).toBe("string");
|
||||||
|
expect(result.token.length).toBe(64); // 32 bytes as hex = 64 characters
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set CSRF token in httpOnly cookie", () => {
|
||||||
|
const mockResponse = {
|
||||||
|
cookie: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
const result = controller.getCsrfToken(mockResponse);
|
||||||
|
|
||||||
|
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||||
|
"csrf-token",
|
||||||
|
result.token,
|
||||||
|
expect.objectContaining({
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "strict",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set secure flag in production", () => {
|
||||||
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
|
const mockResponse = {
|
||||||
|
cookie: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
controller.getCsrfToken(mockResponse);
|
||||||
|
|
||||||
|
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||||
|
"csrf-token",
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
secure: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
process.env.NODE_ENV = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not set secure flag in development", () => {
|
||||||
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
|
||||||
|
const mockResponse = {
|
||||||
|
cookie: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
controller.getCsrfToken(mockResponse);
|
||||||
|
|
||||||
|
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||||
|
"csrf-token",
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
secure: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
process.env.NODE_ENV = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate unique tokens on each call", () => {
|
||||||
|
const mockResponse = {
|
||||||
|
cookie: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
const result1 = controller.getCsrfToken(mockResponse);
|
||||||
|
const result2 = controller.getCsrfToken(mockResponse);
|
||||||
|
|
||||||
|
expect(result1.token).not.toBe(result2.token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set cookie with 24 hour expiry", () => {
|
||||||
|
const mockResponse = {
|
||||||
|
cookie: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
controller.getCsrfToken(mockResponse);
|
||||||
|
|
||||||
|
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||||
|
"csrf-token",
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
35
apps/api/src/common/controllers/csrf.controller.ts
Normal file
35
apps/api/src/common/controllers/csrf.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* CSRF Controller
|
||||||
|
*
|
||||||
|
* Provides CSRF token generation endpoint for client applications.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Controller, Get, Res } from "@nestjs/common";
|
||||||
|
import { Response } from "express";
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
import { SkipCsrf } from "../decorators/skip-csrf.decorator";
|
||||||
|
|
||||||
|
@Controller("api/v1/csrf")
|
||||||
|
export class CsrfController {
|
||||||
|
/**
|
||||||
|
* Generate and set CSRF token
|
||||||
|
* Returns token to client and sets it in httpOnly cookie
|
||||||
|
*/
|
||||||
|
@Get("token")
|
||||||
|
@SkipCsrf() // This endpoint itself doesn't need CSRF protection
|
||||||
|
getCsrfToken(@Res({ passthrough: true }) response: Response): { token: string } {
|
||||||
|
// Generate cryptographically secure random token
|
||||||
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
// Set token in httpOnly cookie
|
||||||
|
response.cookie("csrf-token", token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "strict",
|
||||||
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return token to client (so it can include in X-CSRF-Token header)
|
||||||
|
return { token };
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/api/src/common/decorators/skip-csrf.decorator.ts
Normal file
20
apps/api/src/common/decorators/skip-csrf.decorator.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Skip CSRF Decorator
|
||||||
|
*
|
||||||
|
* Marks an endpoint to skip CSRF protection.
|
||||||
|
* Use for endpoints that have alternative authentication (e.g., signature verification).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Post('incoming/connect')
|
||||||
|
* @SkipCsrf()
|
||||||
|
* async handleIncomingConnection() {
|
||||||
|
* // Signature-based authentication, no CSRF needed
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SetMetadata } from "@nestjs/common";
|
||||||
|
import { SKIP_CSRF_KEY } from "../guards/csrf.guard";
|
||||||
|
|
||||||
|
export const SkipCsrf = () => SetMetadata(SKIP_CSRF_KEY, true);
|
||||||
140
apps/api/src/common/guards/csrf.guard.spec.ts
Normal file
140
apps/api/src/common/guards/csrf.guard.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* CSRF Guard Tests
|
||||||
|
*
|
||||||
|
* Tests CSRF protection using double-submit cookie pattern.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
import { CsrfGuard } from "./csrf.guard";
|
||||||
|
|
||||||
|
describe("CsrfGuard", () => {
|
||||||
|
let guard: CsrfGuard;
|
||||||
|
let reflector: Reflector;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
reflector = new Reflector();
|
||||||
|
guard = new CsrfGuard(reflector);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createContext = (
|
||||||
|
method: string,
|
||||||
|
cookies: Record<string, string> = {},
|
||||||
|
headers: Record<string, string> = {},
|
||||||
|
skipCsrf = false
|
||||||
|
): ExecutionContext => {
|
||||||
|
const request = {
|
||||||
|
method,
|
||||||
|
cookies,
|
||||||
|
headers,
|
||||||
|
path: "/api/test",
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => request,
|
||||||
|
}),
|
||||||
|
getHandler: () => ({}),
|
||||||
|
getClass: () => ({}),
|
||||||
|
getAllAndOverride: vi.fn().mockReturnValue(skipCsrf),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Safe HTTP methods", () => {
|
||||||
|
it("should allow GET requests without CSRF token", () => {
|
||||||
|
const context = createContext("GET");
|
||||||
|
expect(guard.canActivate(context)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow HEAD requests without CSRF token", () => {
|
||||||
|
const context = createContext("HEAD");
|
||||||
|
expect(guard.canActivate(context)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow OPTIONS requests without CSRF token", () => {
|
||||||
|
const context = createContext("OPTIONS");
|
||||||
|
expect(guard.canActivate(context)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Endpoints marked to skip CSRF", () => {
|
||||||
|
it("should allow POST requests when @SkipCsrf() is applied", () => {
|
||||||
|
vi.spyOn(reflector, "getAllAndOverride").mockReturnValue(true);
|
||||||
|
const context = createContext("POST");
|
||||||
|
expect(guard.canActivate(context)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("State-changing methods requiring CSRF", () => {
|
||||||
|
it("should reject POST without CSRF token", () => {
|
||||||
|
const context = createContext("POST");
|
||||||
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||||
|
expect(() => guard.canActivate(context)).toThrow("CSRF token missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject PUT without CSRF token", () => {
|
||||||
|
const context = createContext("PUT");
|
||||||
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject PATCH without CSRF token", () => {
|
||||||
|
const context = createContext("PATCH");
|
||||||
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject DELETE without CSRF token", () => {
|
||||||
|
const context = createContext("DELETE");
|
||||||
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject when only cookie token is present", () => {
|
||||||
|
const context = createContext("POST", { "csrf-token": "abc123" });
|
||||||
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||||
|
expect(() => guard.canActivate(context)).toThrow("CSRF token missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject when only header token is present", () => {
|
||||||
|
const context = createContext("POST", {}, { "x-csrf-token": "abc123" });
|
||||||
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||||
|
expect(() => guard.canActivate(context)).toThrow("CSRF token missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject when tokens do not match", () => {
|
||||||
|
const context = createContext(
|
||||||
|
"POST",
|
||||||
|
{ "csrf-token": "abc123" },
|
||||||
|
{ "x-csrf-token": "xyz789" }
|
||||||
|
);
|
||||||
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||||
|
expect(() => guard.canActivate(context)).toThrow("CSRF token mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow when tokens match", () => {
|
||||||
|
const context = createContext(
|
||||||
|
"POST",
|
||||||
|
{ "csrf-token": "abc123" },
|
||||||
|
{ "x-csrf-token": "abc123" }
|
||||||
|
);
|
||||||
|
expect(guard.canActivate(context)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow PATCH when tokens match", () => {
|
||||||
|
const context = createContext(
|
||||||
|
"PATCH",
|
||||||
|
{ "csrf-token": "token123" },
|
||||||
|
{ "x-csrf-token": "token123" }
|
||||||
|
);
|
||||||
|
expect(guard.canActivate(context)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow DELETE when tokens match", () => {
|
||||||
|
const context = createContext(
|
||||||
|
"DELETE",
|
||||||
|
{ "csrf-token": "delete-token" },
|
||||||
|
{ "x-csrf-token": "delete-token" }
|
||||||
|
);
|
||||||
|
expect(guard.canActivate(context)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
83
apps/api/src/common/guards/csrf.guard.ts
Normal file
83
apps/api/src/common/guards/csrf.guard.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* CSRF Guard
|
||||||
|
*
|
||||||
|
* Implements CSRF protection using double-submit cookie pattern.
|
||||||
|
* Validates that CSRF token in cookie matches token in header.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* - Apply to controllers handling state-changing operations
|
||||||
|
* - Use @SkipCsrf() decorator to exempt specific endpoints
|
||||||
|
* - Safe methods (GET, HEAD, OPTIONS) are automatically exempted
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Logger,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
export const SKIP_CSRF_KEY = "skipCsrf";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CsrfGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(CsrfGuard.name);
|
||||||
|
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
// Check if endpoint is marked to skip CSRF
|
||||||
|
const skipCsrf = this.reflector.getAllAndOverride<boolean>(SKIP_CSRF_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (skipCsrf) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
|
||||||
|
// Exempt safe HTTP methods (GET, HEAD, OPTIONS)
|
||||||
|
if (["GET", "HEAD", "OPTIONS"].includes(request.method)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get CSRF token from cookie and header
|
||||||
|
const cookies = request.cookies as Record<string, string> | undefined;
|
||||||
|
const cookieToken = cookies?.["csrf-token"];
|
||||||
|
const headerToken = request.headers["x-csrf-token"] as string | undefined;
|
||||||
|
|
||||||
|
// Validate tokens exist and match
|
||||||
|
if (!cookieToken || !headerToken) {
|
||||||
|
this.logger.warn({
|
||||||
|
event: "CSRF_TOKEN_MISSING",
|
||||||
|
method: request.method,
|
||||||
|
path: request.path,
|
||||||
|
hasCookie: !!cookieToken,
|
||||||
|
hasHeader: !!headerToken,
|
||||||
|
securityEvent: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new ForbiddenException("CSRF token missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cookieToken !== headerToken) {
|
||||||
|
this.logger.warn({
|
||||||
|
event: "CSRF_TOKEN_MISMATCH",
|
||||||
|
method: request.method,
|
||||||
|
path: request.path,
|
||||||
|
securityEvent: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new ForbiddenException("CSRF token mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import { FederationAuditService } from "./audit.service";
|
|||||||
import { ConnectionService } from "./connection.service";
|
import { ConnectionService } from "./connection.service";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { AdminGuard } from "../auth/guards/admin.guard";
|
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||||
|
import { CsrfGuard } from "../common/guards/csrf.guard";
|
||||||
|
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
|
||||||
import type { PublicInstanceIdentity } from "./types/instance.types";
|
import type { PublicInstanceIdentity } from "./types/instance.types";
|
||||||
import type { ConnectionDetails } from "./types/connection.types";
|
import type { ConnectionDetails } from "./types/connection.types";
|
||||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||||
@@ -25,6 +27,7 @@ import {
|
|||||||
import { FederationConnectionStatus } from "@prisma/client";
|
import { FederationConnectionStatus } from "@prisma/client";
|
||||||
|
|
||||||
@Controller("api/v1/federation")
|
@Controller("api/v1/federation")
|
||||||
|
@UseGuards(CsrfGuard)
|
||||||
export class FederationController {
|
export class FederationController {
|
||||||
private readonly logger = new Logger(FederationController.name);
|
private readonly logger = new Logger(FederationController.name);
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ export class FederationController {
|
|||||||
* Get this instance's public identity
|
* Get this instance's public identity
|
||||||
* No authentication required - this is public information for federation
|
* No authentication required - this is public information for federation
|
||||||
* Rate limit: "long" tier (200 req/hour) - public endpoint
|
* Rate limit: "long" tier (200 req/hour) - public endpoint
|
||||||
|
* CSRF exempt: GET method (safe)
|
||||||
*/
|
*/
|
||||||
@Get("instance")
|
@Get("instance")
|
||||||
@Throttle({ long: { limit: 200, ttl: 3600000 } })
|
@Throttle({ long: { limit: 200, ttl: 3600000 } })
|
||||||
@@ -207,8 +211,10 @@ export class FederationController {
|
|||||||
* Handle incoming connection request from remote instance
|
* Handle incoming connection request from remote instance
|
||||||
* Public endpoint - no authentication required (signature-based verification)
|
* Public endpoint - no authentication required (signature-based verification)
|
||||||
* Rate limit: "short" tier (3 req/sec) - CRITICAL DoS protection (Issue #272)
|
* Rate limit: "short" tier (3 req/sec) - CRITICAL DoS protection (Issue #272)
|
||||||
|
* CSRF exempt: Uses signature-based authentication instead
|
||||||
*/
|
*/
|
||||||
@Post("incoming/connect")
|
@Post("incoming/connect")
|
||||||
|
@SkipCsrf()
|
||||||
@Throttle({ short: { limit: 3, ttl: 1000 } })
|
@Throttle({ short: { limit: 3, ttl: 1000 } })
|
||||||
async handleIncomingConnection(
|
async handleIncomingConnection(
|
||||||
@Body() dto: IncomingConnectionRequestDto
|
@Body() dto: IncomingConnectionRequestDto
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { Navigation } from "@/components/layout/Navigation";
|
import { Navigation } from "@/components/layout/Navigation";
|
||||||
|
import { ChatOverlay } from "@/components/chat";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export default function AuthenticatedLayout({
|
export default function AuthenticatedLayout({
|
||||||
@@ -36,6 +37,7 @@ export default function AuthenticatedLayout({
|
|||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<div className="pt-16">{children}</div>
|
<div className="pt-16">{children}</div>
|
||||||
|
<ChatOverlay />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
270
apps/web/src/components/chat/ChatOverlay.test.tsx
Normal file
270
apps/web/src/components/chat/ChatOverlay.test.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* @file ChatOverlay.test.tsx
|
||||||
|
* @description Tests for the ChatOverlay component
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { ChatOverlay } from "./ChatOverlay";
|
||||||
|
|
||||||
|
// Mock the Chat component
|
||||||
|
vi.mock("./Chat", () => ({
|
||||||
|
Chat: vi.fn(() => <div data-testid="chat-component">Chat Component</div>),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the useChatOverlay hook
|
||||||
|
const mockOpen = vi.fn();
|
||||||
|
const mockClose = vi.fn();
|
||||||
|
const mockMinimize = vi.fn();
|
||||||
|
const mockExpand = vi.fn();
|
||||||
|
const mockToggle = vi.fn();
|
||||||
|
const mockToggleMinimize = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useChatOverlay", () => ({
|
||||||
|
useChatOverlay: vi.fn(() => ({
|
||||||
|
isOpen: false,
|
||||||
|
isMinimized: false,
|
||||||
|
open: mockOpen,
|
||||||
|
close: mockClose,
|
||||||
|
minimize: mockMinimize,
|
||||||
|
expand: mockExpand,
|
||||||
|
toggle: mockToggle,
|
||||||
|
toggleMinimize: mockToggleMinimize,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("ChatOverlay", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Reset mock to default state
|
||||||
|
const { useChatOverlay } = await import("../../hooks/useChatOverlay");
|
||||||
|
vi.mocked(useChatOverlay).mockReturnValue({
|
||||||
|
isOpen: false,
|
||||||
|
isMinimized: false,
|
||||||
|
open: mockOpen,
|
||||||
|
close: mockClose,
|
||||||
|
minimize: mockMinimize,
|
||||||
|
expand: mockExpand,
|
||||||
|
toggle: mockToggle,
|
||||||
|
toggleMinimize: mockToggleMinimize,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when closed", () => {
|
||||||
|
it("should render a floating button to open the chat", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
const openButton = screen.getByRole("button", { name: /open chat/i });
|
||||||
|
expect(openButton).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not render the chat component when closed", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
const chatComponent = screen.queryByTestId("chat-component");
|
||||||
|
expect(chatComponent).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call open when the floating button is clicked", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
const openButton = screen.getByRole("button", { name: /open chat/i });
|
||||||
|
fireEvent.click(openButton);
|
||||||
|
|
||||||
|
expect(mockOpen).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when open", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { useChatOverlay } = await import("../../hooks/useChatOverlay");
|
||||||
|
vi.mocked(useChatOverlay).mockReturnValue({
|
||||||
|
isOpen: true,
|
||||||
|
isMinimized: false,
|
||||||
|
open: mockOpen,
|
||||||
|
close: mockClose,
|
||||||
|
minimize: mockMinimize,
|
||||||
|
expand: mockExpand,
|
||||||
|
toggle: mockToggle,
|
||||||
|
toggleMinimize: mockToggleMinimize,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the chat component", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
const chatComponent = screen.getByTestId("chat-component");
|
||||||
|
expect(chatComponent).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render a close button", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
const closeButton = screen.getByRole("button", { name: /close chat/i });
|
||||||
|
expect(closeButton).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render a minimize button", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
const minimizeButton = screen.getByRole("button", { name: /minimize chat/i });
|
||||||
|
expect(minimizeButton).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call close when the close button is clicked", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
const closeButton = screen.getByRole("button", { name: /close chat/i });
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
|
||||||
|
expect(mockClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call minimize when the minimize button is clicked", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
const minimizeButton = screen.getByRole("button", { name: /minimize chat/i });
|
||||||
|
fireEvent.click(minimizeButton);
|
||||||
|
|
||||||
|
expect(mockMinimize).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when minimized", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { useChatOverlay } = await import("../../hooks/useChatOverlay");
|
||||||
|
vi.mocked(useChatOverlay).mockReturnValue({
|
||||||
|
isOpen: true,
|
||||||
|
isMinimized: true,
|
||||||
|
open: mockOpen,
|
||||||
|
close: mockClose,
|
||||||
|
minimize: mockMinimize,
|
||||||
|
expand: mockExpand,
|
||||||
|
toggle: mockToggle,
|
||||||
|
toggleMinimize: mockToggleMinimize,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not render the chat component when minimized", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
const chatComponent = screen.queryByTestId("chat-component");
|
||||||
|
expect(chatComponent).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render a minimized header", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
const header = screen.getByText(/jarvis/i);
|
||||||
|
expect(header).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call expand when clicking the minimized header", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
const header = screen.getByText(/jarvis/i);
|
||||||
|
fireEvent.click(header);
|
||||||
|
|
||||||
|
expect(mockExpand).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("keyboard shortcuts", () => {
|
||||||
|
it("should toggle chat when Cmd+Shift+J is pressed", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, {
|
||||||
|
key: "j",
|
||||||
|
code: "KeyJ",
|
||||||
|
metaKey: true,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockToggle).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle chat when Ctrl+Shift+J is pressed", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, {
|
||||||
|
key: "j",
|
||||||
|
code: "KeyJ",
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockToggle).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should minimize chat when Escape is pressed and chat is open", async () => {
|
||||||
|
const { useChatOverlay } = await import("../../hooks/useChatOverlay");
|
||||||
|
vi.mocked(useChatOverlay).mockReturnValue({
|
||||||
|
isOpen: true,
|
||||||
|
isMinimized: false,
|
||||||
|
open: mockOpen,
|
||||||
|
close: mockClose,
|
||||||
|
minimize: mockMinimize,
|
||||||
|
expand: mockExpand,
|
||||||
|
toggle: mockToggle,
|
||||||
|
toggleMinimize: mockToggleMinimize,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, {
|
||||||
|
key: "Escape",
|
||||||
|
code: "Escape",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockMinimize).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should open chat when Cmd+K is pressed and chat is closed", async () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
// Wait for component to mount
|
||||||
|
await screen.findByRole("button", { name: /open chat/i });
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, {
|
||||||
|
key: "k",
|
||||||
|
code: "KeyK",
|
||||||
|
metaKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOpen).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should open chat when Ctrl+K is pressed and chat is closed", async () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
// Wait for component to mount
|
||||||
|
await screen.findByRole("button", { name: /open chat/i });
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, {
|
||||||
|
key: "k",
|
||||||
|
code: "KeyK",
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOpen).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("responsive design", () => {
|
||||||
|
it("should render as a sidebar on desktop", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
// Check for desktop-specific classes (will be verified in implementation)
|
||||||
|
// This is a placeholder - actual implementation will have proper responsive classes
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render as a drawer on mobile", () => {
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
// Check for mobile-specific classes (will be verified in implementation)
|
||||||
|
// This is a placeholder - actual implementation will have proper responsive classes
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
214
apps/web/src/components/chat/ChatOverlay.tsx
Normal file
214
apps/web/src/components/chat/ChatOverlay.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* @file ChatOverlay.tsx
|
||||||
|
* @description Persistent chat overlay component that is accessible from any view
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useChatOverlay } from "@/hooks/useChatOverlay";
|
||||||
|
import { Chat } from "./Chat";
|
||||||
|
import type { ChatRef } from "./Chat";
|
||||||
|
|
||||||
|
export function ChatOverlay(): React.JSX.Element {
|
||||||
|
const { isOpen, isMinimized, open, close, minimize, expand, toggle } = useChatOverlay();
|
||||||
|
|
||||||
|
const chatRef = useRef<ChatRef>(null);
|
||||||
|
|
||||||
|
// Global keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||||
|
// Cmd/Ctrl + Shift + J: Toggle chat panel
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === "j" || e.key === "J")) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd/Ctrl + K: Open chat and focus input
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isOpen) {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape: Minimize chat (if open and not minimized)
|
||||||
|
if (e.key === "Escape" && isOpen && !isMinimized) {
|
||||||
|
e.preventDefault();
|
||||||
|
minimize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return (): void => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, isMinimized, open, minimize, toggle]);
|
||||||
|
|
||||||
|
// Render floating button when closed
|
||||||
|
if (!isOpen) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={open}
|
||||||
|
className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 lg:bottom-8 lg:right-8"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--accent-primary))",
|
||||||
|
color: "rgb(var(--text-on-accent))",
|
||||||
|
}}
|
||||||
|
aria-label="Open chat"
|
||||||
|
title="Open Jarvis chat (Cmd+Shift+J)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render minimized header when minimized
|
||||||
|
if (isMinimized) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-0 right-0 z-40 w-full sm:w-96"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={expand}
|
||||||
|
className="flex w-full items-center justify-between border-t px-4 py-3 text-left transition-colors hover:bg-black/5 focus:outline-none focus:ring-2 focus:ring-inset"
|
||||||
|
style={{
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
}}
|
||||||
|
aria-label="Expand chat"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
style={{ color: "rgb(var(--accent-primary))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium" style={{ color: "rgb(var(--text-primary))" }}>
|
||||||
|
Jarvis
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
style={{ color: "rgb(var(--text-secondary))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render full chat overlay when open and expanded
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop for mobile */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||||
|
onClick={close}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Chat Panel */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-y-0 right-0 z-40 flex w-full flex-col border-l sm:w-96 lg:inset-y-16"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between border-b px-4 py-3"
|
||||||
|
style={{ borderColor: "rgb(var(--border-default))" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
style={{ color: "rgb(var(--accent-primary))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-base font-semibold" style={{ color: "rgb(var(--text-primary))" }}>
|
||||||
|
Jarvis
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header Controls */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Minimize Button */}
|
||||||
|
<button
|
||||||
|
onClick={minimize}
|
||||||
|
className="rounded p-1.5 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
||||||
|
aria-label="Minimize chat"
|
||||||
|
title="Minimize (Esc)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
style={{ color: "rgb(var(--text-secondary))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={close}
|
||||||
|
className="rounded p-1.5 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
||||||
|
aria-label="Close chat"
|
||||||
|
title="Close (Cmd+Shift+J)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
style={{ color: "rgb(var(--text-secondary))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat Content */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<Chat ref={chatRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,4 +15,5 @@ export { ChatInput } from "./ChatInput";
|
|||||||
export { MessageList } from "./MessageList";
|
export { MessageList } from "./MessageList";
|
||||||
export { ConversationSidebar, type ConversationSidebarRef } from "./ConversationSidebar";
|
export { ConversationSidebar, type ConversationSidebarRef } from "./ConversationSidebar";
|
||||||
export { BackendStatusBanner } from "./BackendStatusBanner";
|
export { BackendStatusBanner } from "./BackendStatusBanner";
|
||||||
|
export { ChatOverlay } from "./ChatOverlay";
|
||||||
export type { Message } from "@/hooks/useChat";
|
export type { Message } from "@/hooks/useChat";
|
||||||
|
|||||||
276
apps/web/src/hooks/useChatOverlay.test.ts
Normal file
276
apps/web/src/hooks/useChatOverlay.test.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
/**
|
||||||
|
* @file useChatOverlay.test.ts
|
||||||
|
* @description Tests for the useChatOverlay hook that manages chat overlay state
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { useChatOverlay } from "./useChatOverlay";
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = ((): Storage => {
|
||||||
|
let store: Record<string, string> = {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getItem: (key: string): string | null => store[key] ?? null,
|
||||||
|
setItem: (key: string, value: string): void => {
|
||||||
|
store[key] = value;
|
||||||
|
},
|
||||||
|
removeItem: (key: string): void => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||||
|
delete store[key];
|
||||||
|
},
|
||||||
|
clear: (): void => {
|
||||||
|
store = {};
|
||||||
|
},
|
||||||
|
get length(): number {
|
||||||
|
return Object.keys(store).length;
|
||||||
|
},
|
||||||
|
key: (index: number): string | null => {
|
||||||
|
const keys = Object.keys(store);
|
||||||
|
return keys[index] ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: localStorageMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useChatOverlay", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial state", () => {
|
||||||
|
it("should initialize with closed and not minimized state", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
expect(result.current.isOpen).toBe(false);
|
||||||
|
expect(result.current.isMinimized).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should restore state from localStorage if available", () => {
|
||||||
|
localStorageMock.setItem(
|
||||||
|
"chatOverlayState",
|
||||||
|
JSON.stringify({ isOpen: true, isMinimized: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
expect(result.current.isOpen).toBe(true);
|
||||||
|
expect(result.current.isMinimized).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle invalid localStorage data gracefully", () => {
|
||||||
|
localStorageMock.setItem("chatOverlayState", "invalid json");
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
expect(result.current.isOpen).toBe(false);
|
||||||
|
expect(result.current.isMinimized).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("open", () => {
|
||||||
|
it("should open the chat overlay", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isOpen).toBe(true);
|
||||||
|
expect(result.current.isMinimized).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist state to localStorage when opening", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as {
|
||||||
|
isOpen: boolean;
|
||||||
|
isMinimized: boolean;
|
||||||
|
};
|
||||||
|
expect(stored.isOpen).toBe(true);
|
||||||
|
expect(stored.isMinimized).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("close", () => {
|
||||||
|
it("should close the chat overlay", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist state to localStorage when closing", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as {
|
||||||
|
isOpen: boolean;
|
||||||
|
isMinimized: boolean;
|
||||||
|
};
|
||||||
|
expect(stored.isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("minimize", () => {
|
||||||
|
it("should minimize the chat overlay", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.minimize();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isOpen).toBe(true);
|
||||||
|
expect(result.current.isMinimized).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist minimized state to localStorage", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.minimize();
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as {
|
||||||
|
isOpen: boolean;
|
||||||
|
isMinimized: boolean;
|
||||||
|
};
|
||||||
|
expect(stored.isMinimized).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("expand", () => {
|
||||||
|
it("should expand the minimized chat overlay", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
result.current.minimize();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.expand();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isMinimized).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist expanded state to localStorage", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
result.current.minimize();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.expand();
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as {
|
||||||
|
isOpen: boolean;
|
||||||
|
isMinimized: boolean;
|
||||||
|
};
|
||||||
|
expect(stored.isMinimized).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggle", () => {
|
||||||
|
it("should toggle the chat overlay open state", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
// Initially closed
|
||||||
|
expect(result.current.isOpen).toBe(false);
|
||||||
|
|
||||||
|
// Toggle to open
|
||||||
|
act(() => {
|
||||||
|
result.current.toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isOpen).toBe(true);
|
||||||
|
|
||||||
|
// Toggle to close
|
||||||
|
act(() => {
|
||||||
|
result.current.toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not change minimized state when toggling", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
result.current.minimize();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isMinimized).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should close but keep minimized state for next open
|
||||||
|
expect(result.current.isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleMinimize", () => {
|
||||||
|
it("should toggle the minimize state", () => {
|
||||||
|
const { result } = renderHook(() => useChatOverlay());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initially not minimized
|
||||||
|
expect(result.current.isMinimized).toBe(false);
|
||||||
|
|
||||||
|
// Toggle to minimized
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleMinimize();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isMinimized).toBe(true);
|
||||||
|
|
||||||
|
// Toggle back to expanded
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleMinimize();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isMinimized).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
109
apps/web/src/hooks/useChatOverlay.ts
Normal file
109
apps/web/src/hooks/useChatOverlay.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* @file useChatOverlay.ts
|
||||||
|
* @description Hook for managing the global chat overlay state
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
interface ChatOverlayState {
|
||||||
|
isOpen: boolean;
|
||||||
|
isMinimized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseChatOverlayResult extends ChatOverlayState {
|
||||||
|
open: () => void;
|
||||||
|
close: () => void;
|
||||||
|
minimize: () => void;
|
||||||
|
expand: () => void;
|
||||||
|
toggle: () => void;
|
||||||
|
toggleMinimize: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "chatOverlayState";
|
||||||
|
|
||||||
|
const DEFAULT_STATE: ChatOverlayState = {
|
||||||
|
isOpen: false,
|
||||||
|
isMinimized: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load state from localStorage
|
||||||
|
*/
|
||||||
|
function loadState(): ChatOverlayState {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return DEFAULT_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
return JSON.parse(stored) as ChatOverlayState;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to load chat overlay state from localStorage:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save state to localStorage
|
||||||
|
*/
|
||||||
|
function saveState(state: ChatOverlayState): void {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to save chat overlay state to localStorage:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing chat overlay state
|
||||||
|
* Persists state to localStorage for consistency across page refreshes
|
||||||
|
*/
|
||||||
|
export function useChatOverlay(): UseChatOverlayResult {
|
||||||
|
const [state, setState] = useState<ChatOverlayState>(loadState);
|
||||||
|
|
||||||
|
// Persist state changes to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
saveState(state);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const open = useCallback(() => {
|
||||||
|
setState({ isOpen: true, isMinimized: false });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, isOpen: false }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const minimize = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, isMinimized: true }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const expand = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, isMinimized: false }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, isOpen: !prev.isOpen }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleMinimize = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, isMinimized: !prev.isMinimized }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
minimize,
|
||||||
|
expand,
|
||||||
|
toggle,
|
||||||
|
toggleMinimize,
|
||||||
|
};
|
||||||
|
}
|
||||||
171
docs/scratchpads/278-csrf-protection.md
Normal file
171
docs/scratchpads/278-csrf-protection.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# Issue #278: Implement CSRF protection on state-changing endpoints
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement CSRF protection for all state-changing endpoints (POST, PATCH, DELETE) to prevent CSRF attacks.
|
||||||
|
|
||||||
|
## Security Impact
|
||||||
|
|
||||||
|
**Vulnerable Endpoints:**
|
||||||
|
|
||||||
|
- Connection initiation (`POST /api/v1/federation/connections/initiate`)
|
||||||
|
- Connection acceptance (`POST /api/v1/federation/connections/:id/accept`)
|
||||||
|
- Agent spawn (`POST /api/v1/agents/spawn`)
|
||||||
|
- Identity linking (POST endpoints in auth module)
|
||||||
|
|
||||||
|
## Modern CSRF Protection Approaches
|
||||||
|
|
||||||
|
### Option 1: SameSite Cookie Attribute (Simplest)
|
||||||
|
|
||||||
|
- Set `SameSite=Strict` or `SameSite=Lax` on session cookies
|
||||||
|
- No code changes required if already using sessions
|
||||||
|
- Modern browser support
|
||||||
|
- **Limitation**: Doesn't protect against subdomain attacks
|
||||||
|
|
||||||
|
### Option 2: Double Submit Cookie Pattern
|
||||||
|
|
||||||
|
- Generate CSRF token, store in cookie and send in header
|
||||||
|
- Validate that cookie and header match
|
||||||
|
- No server-side session storage required
|
||||||
|
- Works well with stateless apps
|
||||||
|
|
||||||
|
### Option 3: Synchronizer Token Pattern
|
||||||
|
|
||||||
|
- Generate CSRF token per session
|
||||||
|
- Store in session, validate on each request
|
||||||
|
- Requires session storage
|
||||||
|
- Most secure but complex
|
||||||
|
|
||||||
|
## Recommended Approach
|
||||||
|
|
||||||
|
**Use Double Submit Cookie Pattern:**
|
||||||
|
|
||||||
|
1. Generate CSRF token on first authenticated request
|
||||||
|
2. Set token in httpOnly cookie
|
||||||
|
3. Client includes token in X-CSRF-Token header
|
||||||
|
4. Server validates cookie matches header
|
||||||
|
|
||||||
|
**Exempt signature-based endpoints:**
|
||||||
|
|
||||||
|
- Federation incoming connections (already signature-verified)
|
||||||
|
- Any public endpoints that don't require authentication
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### 1. Create CSRF Guard
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/common/guards/csrf.guard.ts
|
||||||
|
@Injectable()
|
||||||
|
export class CsrfGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
// Exempt GET, HEAD, OPTIONS (safe methods)
|
||||||
|
if (["GET", "HEAD", "OPTIONS"].includes(request.method)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get token from cookie and header
|
||||||
|
const cookieToken = request.cookies["csrf-token"];
|
||||||
|
const headerToken = request.headers["x-csrf-token"];
|
||||||
|
|
||||||
|
// Validate tokens match
|
||||||
|
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
|
||||||
|
throw new ForbiddenException("Invalid CSRF token");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. CSRF Token Generation Endpoint
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Get('csrf-token')
|
||||||
|
getCsrfToken(@Res() response: Response): { token: string } {
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
response.cookie('csrf-token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
});
|
||||||
|
return { token };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Apply Guard Globally (with exemptions)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// main.ts
|
||||||
|
app.useGlobalGuards(new CsrfGuard());
|
||||||
|
```
|
||||||
|
|
||||||
|
Or per-controller/route:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@UseGuards(CsrfGuard)
|
||||||
|
@Controller("api/v1/federation")
|
||||||
|
export class FederationController {
|
||||||
|
// Endpoints automatically protected
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Exempt Signature-Based Endpoints
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Post('incoming/connect')
|
||||||
|
@SkipCsrf() // Custom decorator
|
||||||
|
async handleIncomingConnection() {
|
||||||
|
// Signature verification provides authentication
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternative: Check Existing Protection
|
||||||
|
|
||||||
|
Before implementing, verify if CSRF protection already exists:
|
||||||
|
|
||||||
|
1. Check if session cookies use SameSite attribute
|
||||||
|
2. Check for existing CSRF middleware
|
||||||
|
3. Check authentication middleware configuration
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
1. Test CSRF token generation endpoint
|
||||||
|
2. Test protected endpoint rejects missing token
|
||||||
|
3. Test protected endpoint rejects mismatched token
|
||||||
|
4. Test protected endpoint accepts valid token
|
||||||
|
5. Test exempted endpoints work without token
|
||||||
|
6. Test safe methods (GET) work without token
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [ ] Create scratchpad
|
||||||
|
- [ ] Check for existing CSRF protection
|
||||||
|
- [ ] Decide on implementation approach
|
||||||
|
- [ ] Create CSRF guard
|
||||||
|
- [ ] Create token generation endpoint
|
||||||
|
- [ ] Apply guard to controllers
|
||||||
|
- [ ] Add exemptions for signature-based endpoints
|
||||||
|
- [ ] Add tests
|
||||||
|
- [ ] Update frontend to include tokens
|
||||||
|
- [ ] Run quality gates
|
||||||
|
- [ ] Commit and push
|
||||||
|
- [ ] Close issue
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
**Important Considerations:**
|
||||||
|
|
||||||
|
1. Don't break existing API consumers
|
||||||
|
2. Ensure frontend can get and use tokens
|
||||||
|
3. Document token usage for API clients
|
||||||
|
4. Consider backward compatibility
|
||||||
|
|
||||||
|
**Scope Decision:**
|
||||||
|
Given this is backend-focused and the frontend integration is complex, consider:
|
||||||
|
|
||||||
|
- Implementing SameSite cookie protection (simpler, immediate benefit)
|
||||||
|
- OR implementing CSRF guard with proper exemptions
|
||||||
|
- Document that frontend integration is required for full protection
|
||||||
216
docs/scratchpads/42-jarvis-chat-overlay.md
Normal file
216
docs/scratchpads/42-jarvis-chat-overlay.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# Issue #42: Jarvis Chat Overlay
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement a persistent Jarvis chat overlay accessible from any view in the HUD. The chat should maintain state across navigation, support markdown rendering, integrate with ClawdBot via WebSocket, and be context-aware of the current view.
|
||||||
|
|
||||||
|
## Requirements Summary
|
||||||
|
|
||||||
|
### UI Components
|
||||||
|
|
||||||
|
- Chat overlay with 3 states: Minimized (icon), Collapsed (header), Expanded (full chat)
|
||||||
|
- Message history with markdown rendering and code syntax highlighting
|
||||||
|
- Input field with send button
|
||||||
|
- Typing indicator
|
||||||
|
- Message timestamps
|
||||||
|
- PDA-friendly response formatting
|
||||||
|
- Responsive design (sidebar for desktop, drawer for mobile)
|
||||||
|
|
||||||
|
### Backend Integration
|
||||||
|
|
||||||
|
- WebSocket connection to ClawdBot gateway
|
||||||
|
- POST /api/chat/message endpoint
|
||||||
|
- GET /api/chat/history endpoint
|
||||||
|
- WebSocket /ws/chat endpoint
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Context awareness (current view, entity type, entity ID)
|
||||||
|
- Deep linking from Jarvis responses
|
||||||
|
- Keyboard shortcuts:
|
||||||
|
- Cmd/Ctrl + K: Focus chat input
|
||||||
|
- Escape: Minimize chat
|
||||||
|
- Cmd/Ctrl + Shift + J: Toggle chat panel
|
||||||
|
- Chat history persistence
|
||||||
|
- State persistence across navigation
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
### Phase 1: Frontend Components
|
||||||
|
|
||||||
|
1. Create ChatOverlay component (apps/web)
|
||||||
|
2. Create ChatMessage component for rendering messages
|
||||||
|
3. Create ChatInput component
|
||||||
|
4. Implement state management (React Context or Zustand)
|
||||||
|
5. Add keyboard shortcuts
|
||||||
|
6. Implement responsive design
|
||||||
|
|
||||||
|
### Phase 2: Backend API
|
||||||
|
|
||||||
|
1. Create chat module in apps/api
|
||||||
|
2. Implement POST /api/chat/message endpoint
|
||||||
|
3. Implement GET /api/chat/history endpoint
|
||||||
|
4. Set up WebSocket gateway for /ws/chat
|
||||||
|
5. Integrate with ClawdBot
|
||||||
|
|
||||||
|
### Phase 3: Integration & Polish
|
||||||
|
|
||||||
|
1. Connect frontend to backend WebSocket
|
||||||
|
2. Implement context awareness
|
||||||
|
3. Add markdown rendering
|
||||||
|
4. Add code syntax highlighting
|
||||||
|
5. Implement chat history persistence
|
||||||
|
6. Add loading states and error handling
|
||||||
|
|
||||||
|
## Codebase Structure
|
||||||
|
|
||||||
|
### Frontend (apps/web/)
|
||||||
|
|
||||||
|
- `app/` - Next.js 16 App Router
|
||||||
|
- `layout.tsx` - Root layout with providers
|
||||||
|
- `(authenticated)/layout.tsx` - Authenticated layout (where overlay will be added)
|
||||||
|
- `components/` - React components
|
||||||
|
- `chat/` - Existing chat components (Chat, ChatInput, MessageList, ConversationSidebar)
|
||||||
|
- `hud/` - HUD widgets
|
||||||
|
- `ui/` - Shadcn/ui components
|
||||||
|
- `hooks/` - Custom hooks
|
||||||
|
- `useChat.ts` - Chat state management
|
||||||
|
- `useWebSocket.ts` - WebSocket connection
|
||||||
|
- `lib/` - Utilities and shared logic
|
||||||
|
|
||||||
|
### Backend (apps/api/)
|
||||||
|
|
||||||
|
- `src/` - NestJS application
|
||||||
|
- `brain/` - Brain query service (already exists)
|
||||||
|
- `websocket/` - WebSocket gateway (already exists)
|
||||||
|
- Chat endpoints already exist via brain module
|
||||||
|
|
||||||
|
## Key Findings
|
||||||
|
|
||||||
|
✅ Chat component already fully implemented
|
||||||
|
✅ WebSocket connection already exists
|
||||||
|
✅ Backend API already exists (brain module)
|
||||||
|
✅ Message rendering with markdown already works
|
||||||
|
|
||||||
|
**What needs to be built:**
|
||||||
|
|
||||||
|
1. ChatOverlay wrapper component with minimize/expand/collapse states
|
||||||
|
2. useChatOverlay hook for global state management
|
||||||
|
3. Integration into authenticated layout
|
||||||
|
4. Keyboard shortcuts (Cmd+K, Escape, Cmd+Shift+J)
|
||||||
|
5. Responsive design (sidebar for desktop, drawer for mobile)
|
||||||
|
6. Context awareness (pass current view/entity to chat)
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: State Management Hook
|
||||||
|
|
||||||
|
Create `apps/web/src/hooks/useChatOverlay.ts`:
|
||||||
|
|
||||||
|
- State: isOpen, isMinimized, isExpanded
|
||||||
|
- Methods: open, close, minimize, expand, toggle
|
||||||
|
- Persist state to localStorage
|
||||||
|
- **TDD: Write tests first**
|
||||||
|
|
||||||
|
### Phase 2: ChatOverlay Component
|
||||||
|
|
||||||
|
Create `apps/web/src/components/chat/ChatOverlay.tsx`:
|
||||||
|
|
||||||
|
- Wrap existing Chat component
|
||||||
|
- Add minimize/expand/collapse UI controls
|
||||||
|
- Responsive design (sidebar vs drawer)
|
||||||
|
- Use useChatOverlay hook for state
|
||||||
|
- **TDD: Write tests first**
|
||||||
|
|
||||||
|
### Phase 3: Keyboard Shortcuts
|
||||||
|
|
||||||
|
Add global keyboard listener:
|
||||||
|
|
||||||
|
- Cmd/Ctrl + K: Focus chat input (already exists in Chat.tsx, update to also open overlay)
|
||||||
|
- Escape: Minimize chat
|
||||||
|
- Cmd/Ctrl + Shift + J: Toggle chat panel
|
||||||
|
- **TDD: Write tests first**
|
||||||
|
|
||||||
|
### Phase 4: Integration
|
||||||
|
|
||||||
|
- Add ChatOverlay to authenticated layout
|
||||||
|
- Add context awareness (pass current route/view)
|
||||||
|
- Test across different pages
|
||||||
|
|
||||||
|
### Phase 5: Polish
|
||||||
|
|
||||||
|
- Animations for expand/collapse
|
||||||
|
- Ensure PDA-friendly design
|
||||||
|
- Add loading states
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] Create scratchpad
|
||||||
|
- [x] Explore current codebase structure
|
||||||
|
- [x] Identify existing chat implementation
|
||||||
|
- [x] Identify backend WebSocket infrastructure
|
||||||
|
- [x] Plan component architecture
|
||||||
|
- [x] Write tests for useChatOverlay hook (14 tests)
|
||||||
|
- [x] Implement useChatOverlay hook (all tests passing)
|
||||||
|
- [x] Write tests for ChatOverlay component (18 tests)
|
||||||
|
- [x] Implement ChatOverlay component (all tests passing)
|
||||||
|
- [x] Add keyboard shortcuts (Cmd+K, Escape, Cmd+Shift+J)
|
||||||
|
- [x] Write tests for keyboard shortcuts (included in component tests)
|
||||||
|
- [x] Integrate into authenticated layout
|
||||||
|
- [ ] Test context awareness (will be added later as enhancement)
|
||||||
|
- [x] Test responsive design (basic responsive classes added)
|
||||||
|
- [ ] Add animations (basic transitions added, can enhance later)
|
||||||
|
- [ ] Run quality checks (test, lint, build)
|
||||||
|
- [ ] Create PR
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Files Created
|
||||||
|
|
||||||
|
1. `apps/web/src/hooks/useChatOverlay.ts` - State management hook
|
||||||
|
2. `apps/web/src/hooks/useChatOverlay.test.ts` - Hook tests (14 tests, all passing)
|
||||||
|
3. `apps/web/src/components/chat/ChatOverlay.tsx` - Overlay component
|
||||||
|
4. `apps/web/src/components/chat/ChatOverlay.test.tsx` - Component tests (18 tests, all passing)
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
1. `apps/web/src/components/chat/index.ts` - Added ChatOverlay export
|
||||||
|
2. `apps/web/src/app/(authenticated)/layout.tsx` - Integrated ChatOverlay
|
||||||
|
|
||||||
|
### Features Implemented
|
||||||
|
|
||||||
|
✅ Persistent chat overlay accessible from any authenticated view
|
||||||
|
✅ Three states: Closed (floating button), Open (full panel), Minimized (header only)
|
||||||
|
✅ Keyboard shortcuts:
|
||||||
|
|
||||||
|
- Cmd/Ctrl + K: Open chat (when closed)
|
||||||
|
- Escape: Minimize chat (when open)
|
||||||
|
- Cmd/Ctrl + Shift + J: Toggle chat panel
|
||||||
|
✅ State persistence via localStorage
|
||||||
|
✅ Responsive design (full-width on mobile, sidebar on desktop)
|
||||||
|
✅ Wraps existing Chat component (reuses all chat functionality)
|
||||||
|
✅ PDA-friendly design with calm colors
|
||||||
|
✅ Accessibility labels and ARIA attributes
|
||||||
|
|
||||||
|
### Features Not Yet Implemented (Future Enhancements)
|
||||||
|
|
||||||
|
- Context awareness (passing current view/entity to chat) - Can be added later
|
||||||
|
- Enhanced animations (current implementation has basic transitions)
|
||||||
|
- Deep linking from Jarvis responses - Requires backend changes
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit tests for chat components
|
||||||
|
- Integration tests for API endpoints
|
||||||
|
- E2E tests for chat overlay interaction
|
||||||
|
- WebSocket connection tests
|
||||||
|
- Keyboard shortcut tests
|
||||||
|
- Responsive design tests
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Need to understand existing component patterns in apps/web
|
||||||
|
- Need to check if WebSocket infrastructure already exists
|
||||||
|
- Need to verify ClawdBot integration approach
|
||||||
|
- Should follow PDA-friendly design principles from DESIGN-PRINCIPLES.md
|
||||||
Reference in New Issue
Block a user