Compare commits
11 Commits
8b4c565f20
...
feat/ms19-
| Author | SHA1 | Date | |
|---|---|---|---|
| e41fedb3c2 | |||
| 5ba77d8952 | |||
| 7de0e734b0 | |||
| 6290fc3d53 | |||
| 9f4de1682f | |||
| 374ca7ace3 | |||
| 72c64d2eeb | |||
| 5f6c520a98 | |||
| 9a7673bea2 | |||
| 91934b9933 | |||
| 7f89682946 |
@@ -66,6 +66,7 @@
|
|||||||
"marked-gfm-heading-id": "^4.1.3",
|
"marked-gfm-heading-id": "^4.1.3",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
"matrix-bot-sdk": "^0.8.0",
|
"matrix-bot-sdk": "^0.8.0",
|
||||||
|
"node-pty": "^1.0.0",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"openai": "^6.17.0",
|
"openai": "^6.17.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TerminalSessionStatus" AS ENUM ('ACTIVE', 'CLOSED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "terminal_sessions" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"workspace_id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL DEFAULT 'Terminal',
|
||||||
|
"status" "TerminalSessionStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"closed_at" TIMESTAMPTZ,
|
||||||
|
|
||||||
|
CONSTRAINT "terminal_sessions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "terminal_sessions_workspace_id_idx" ON "terminal_sessions"("workspace_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "terminal_sessions_workspace_id_status_idx" ON "terminal_sessions"("workspace_id", "status");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "terminal_sessions" ADD CONSTRAINT "terminal_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -206,6 +206,11 @@ enum CredentialScope {
|
|||||||
SYSTEM
|
SYSTEM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TerminalSessionStatus {
|
||||||
|
ACTIVE
|
||||||
|
CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// MODELS
|
// MODELS
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -297,6 +302,7 @@ model Workspace {
|
|||||||
federationEventSubscriptions FederationEventSubscription[]
|
federationEventSubscriptions FederationEventSubscription[]
|
||||||
llmUsageLogs LlmUsageLog[]
|
llmUsageLogs LlmUsageLog[]
|
||||||
userCredentials UserCredential[]
|
userCredentials UserCredential[]
|
||||||
|
terminalSessions TerminalSession[]
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@map("workspaces")
|
@@map("workspaces")
|
||||||
@@ -1507,3 +1513,23 @@ model LlmUsageLog {
|
|||||||
@@index([conversationId])
|
@@index([conversationId])
|
||||||
@@map("llm_usage_logs")
|
@@map("llm_usage_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TERMINAL MODULE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model TerminalSession {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
workspaceId String @map("workspace_id") @db.Uuid
|
||||||
|
name String @default("Terminal")
|
||||||
|
status TerminalSessionStatus @default(ACTIVE)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||||
|
closedAt DateTime? @map("closed_at") @db.Timestamptz
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([workspaceId])
|
||||||
|
@@index([workspaceId, status])
|
||||||
|
@@map("terminal_sessions")
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { CredentialsModule } from "./credentials/credentials.module";
|
|||||||
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
||||||
import { SpeechModule } from "./speech/speech.module";
|
import { SpeechModule } from "./speech/speech.module";
|
||||||
import { DashboardModule } from "./dashboard/dashboard.module";
|
import { DashboardModule } from "./dashboard/dashboard.module";
|
||||||
|
import { TerminalModule } from "./terminal/terminal.module";
|
||||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -103,6 +104,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
MosaicTelemetryModule,
|
MosaicTelemetryModule,
|
||||||
SpeechModule,
|
SpeechModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
|
TerminalModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -254,6 +254,10 @@ export function createAuth(prisma: PrismaClient) {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
plugins: [...getOidcPlugins()],
|
plugins: [...getOidcPlugins()],
|
||||||
|
logger: {
|
||||||
|
disabled: false,
|
||||||
|
level: "error",
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max
|
expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max
|
||||||
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request
|
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request
|
||||||
|
|||||||
@@ -123,6 +123,14 @@ export class AuthController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
|
// BetterAuth writes responses directly — catch silent 500s that bypass NestJS error handling
|
||||||
|
if (res.statusCode >= 500) {
|
||||||
|
this.logger.error(
|
||||||
|
`BetterAuth returned ${String(res.statusCode)} for ${req.method} ${req.url} from ${clientIp}` +
|
||||||
|
` — check container stdout for '# SERVER_ERROR' details`
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const stack = error instanceof Error ? error.stack : undefined;
|
const stack = error instanceof Error ? error.stack : undefined;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface AuthenticatedRequest extends Request {
|
|||||||
user?: AuthenticatedUser;
|
user?: AuthenticatedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller("api/v1/csrf")
|
@Controller("v1/csrf")
|
||||||
export class CsrfController {
|
export class CsrfController {
|
||||||
constructor(private readonly csrfService: CsrfService) {}
|
constructor(private readonly csrfService: CsrfService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -174,17 +174,19 @@ describe("CsrfGuard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Session binding validation", () => {
|
describe("Session binding validation", () => {
|
||||||
it("should reject when user is not authenticated", () => {
|
it("should allow when user context is not yet available (global guard ordering)", () => {
|
||||||
|
// CsrfGuard runs as APP_GUARD before per-controller AuthGuard,
|
||||||
|
// so request.user may not be populated. Double-submit cookie match
|
||||||
|
// is sufficient protection in this case.
|
||||||
const token = generateValidToken("user-123");
|
const token = generateValidToken("user-123");
|
||||||
const context = createContext(
|
const context = createContext(
|
||||||
"POST",
|
"POST",
|
||||||
{ "csrf-token": token },
|
{ "csrf-token": token },
|
||||||
{ "x-csrf-token": token },
|
{ "x-csrf-token": token },
|
||||||
false
|
false
|
||||||
// No userId - unauthenticated
|
// No userId - AuthGuard hasn't run yet
|
||||||
);
|
);
|
||||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
expect(guard.canActivate(context)).toBe(true);
|
||||||
expect(() => guard.canActivate(context)).toThrow("CSRF validation requires authentication");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject token from different session", () => {
|
it("should reject token from different session", () => {
|
||||||
|
|||||||
@@ -89,30 +89,30 @@ export class CsrfGuard implements CanActivate {
|
|||||||
throw new ForbiddenException("CSRF token mismatch");
|
throw new ForbiddenException("CSRF token mismatch");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate session binding via HMAC
|
// Validate session binding via HMAC when user context is available.
|
||||||
|
// CsrfGuard is a global guard (APP_GUARD) that runs before per-controller
|
||||||
|
// AuthGuard, so request.user may not be populated yet. In that case, the
|
||||||
|
// double-submit cookie match above is sufficient CSRF protection.
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
if (!userId) {
|
if (userId) {
|
||||||
this.logger.warn({
|
if (!this.csrfService.validateToken(cookieToken, userId)) {
|
||||||
event: "CSRF_NO_USER_CONTEXT",
|
this.logger.warn({
|
||||||
|
event: "CSRF_SESSION_BINDING_INVALID",
|
||||||
|
method: request.method,
|
||||||
|
path: request.path,
|
||||||
|
securityEvent: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new ForbiddenException("CSRF token not bound to session");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.debug({
|
||||||
|
event: "CSRF_SKIP_SESSION_BINDING",
|
||||||
method: request.method,
|
method: request.method,
|
||||||
path: request.path,
|
path: request.path,
|
||||||
securityEvent: true,
|
reason: "User context not yet available (global guard runs before AuthGuard)",
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new ForbiddenException("CSRF validation requires authentication");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.csrfService.validateToken(cookieToken, userId)) {
|
|
||||||
this.logger.warn({
|
|
||||||
event: "CSRF_SESSION_BINDING_INVALID",
|
|
||||||
method: request.method,
|
|
||||||
path: request.path,
|
|
||||||
securityEvent: true,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
throw new ForbiddenException("CSRF token not bound to session");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
|
|||||||
import type { CommandMessageDetails, CommandResponse } from "./types/message.types";
|
import type { CommandMessageDetails, CommandResponse } from "./types/message.types";
|
||||||
import type { FederationMessageStatus } from "@prisma/client";
|
import type { FederationMessageStatus } from "@prisma/client";
|
||||||
|
|
||||||
@Controller("api/v1/federation")
|
@Controller("v1/federation")
|
||||||
export class CommandController {
|
export class CommandController {
|
||||||
private readonly logger = new Logger(CommandController.name);
|
private readonly logger = new Logger(CommandController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
IncomingEventAckDto,
|
IncomingEventAckDto,
|
||||||
} from "./dto/event.dto";
|
} from "./dto/event.dto";
|
||||||
|
|
||||||
@Controller("api/v1/federation")
|
@Controller("v1/federation")
|
||||||
export class EventController {
|
export class EventController {
|
||||||
private readonly logger = new Logger(EventController.name);
|
private readonly logger = new Logger(EventController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
ValidateFederatedTokenDto,
|
ValidateFederatedTokenDto,
|
||||||
} from "./dto/federated-auth.dto";
|
} from "./dto/federated-auth.dto";
|
||||||
|
|
||||||
@Controller("api/v1/federation/auth")
|
@Controller("v1/federation/auth")
|
||||||
export class FederationAuthController {
|
export class FederationAuthController {
|
||||||
private readonly logger = new Logger(FederationAuthController.name);
|
private readonly logger = new Logger(FederationAuthController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
} from "./dto/connection.dto";
|
} from "./dto/connection.dto";
|
||||||
import { FederationConnectionStatus } from "@prisma/client";
|
import { FederationConnectionStatus } from "@prisma/client";
|
||||||
|
|
||||||
@Controller("api/v1/federation")
|
@Controller("v1/federation")
|
||||||
export class FederationController {
|
export class FederationController {
|
||||||
private readonly logger = new Logger(FederationController.name);
|
private readonly logger = new Logger(FederationController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
|
|||||||
import type { QueryMessageDetails, QueryResponse } from "./types/message.types";
|
import type { QueryMessageDetails, QueryResponse } from "./types/message.types";
|
||||||
import type { FederationMessageStatus } from "@prisma/client";
|
import type { FederationMessageStatus } from "@prisma/client";
|
||||||
|
|
||||||
@Controller("api/v1/federation")
|
@Controller("v1/federation")
|
||||||
export class QueryController {
|
export class QueryController {
|
||||||
private readonly logger = new Logger(QueryController.name);
|
private readonly logger = new Logger(QueryController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { ValidationPipe } from "@nestjs/common";
|
import { RequestMethod, ValidationPipe } from "@nestjs/common";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { getTrustedOrigins } from "./auth/auth.config";
|
import { getTrustedOrigins } from "./auth/auth.config";
|
||||||
@@ -47,6 +47,16 @@ async function bootstrap() {
|
|||||||
|
|
||||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||||
|
|
||||||
|
// Set global API prefix — all routes get /api/* except auth and health
|
||||||
|
// Auth routes are excluded because BetterAuth expects /auth/* paths
|
||||||
|
// Health is excluded because Docker healthchecks hit /health directly
|
||||||
|
app.setGlobalPrefix("api", {
|
||||||
|
exclude: [
|
||||||
|
{ path: "health", method: RequestMethod.GET },
|
||||||
|
{ path: "auth/(.*)", method: RequestMethod.ALL },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// Configure CORS for cookie-based authentication
|
// Configure CORS for cookie-based authentication
|
||||||
// Origin list is shared with BetterAuth trustedOrigins via getTrustedOrigins()
|
// Origin list is shared with BetterAuth trustedOrigins via getTrustedOrigins()
|
||||||
const trustedOrigins = getTrustedOrigins();
|
const trustedOrigins = getTrustedOrigins();
|
||||||
|
|||||||
53
apps/api/src/terminal/terminal-session.dto.ts
Normal file
53
apps/api/src/terminal/terminal-session.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Terminal Session DTOs
|
||||||
|
*
|
||||||
|
* Data Transfer Objects for terminal session persistence endpoints.
|
||||||
|
* Validated using class-validator decorators.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IsString, IsOptional, MaxLength, IsEnum, IsUUID } from "class-validator";
|
||||||
|
import { TerminalSessionStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a new terminal session record.
|
||||||
|
*/
|
||||||
|
export class CreateTerminalSessionDto {
|
||||||
|
@IsString()
|
||||||
|
@IsUUID()
|
||||||
|
workspaceId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for querying terminal sessions by workspace.
|
||||||
|
*/
|
||||||
|
export class FindTerminalSessionsByWorkspaceDto {
|
||||||
|
@IsString()
|
||||||
|
@IsUUID()
|
||||||
|
workspaceId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response shape for a terminal session.
|
||||||
|
*/
|
||||||
|
export class TerminalSessionResponseDto {
|
||||||
|
id!: string;
|
||||||
|
workspaceId!: string;
|
||||||
|
name!: string;
|
||||||
|
status!: TerminalSessionStatus;
|
||||||
|
createdAt!: Date;
|
||||||
|
closedAt!: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for filtering terminal sessions by status.
|
||||||
|
*/
|
||||||
|
export class TerminalSessionStatusFilterDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TerminalSessionStatus)
|
||||||
|
status?: TerminalSessionStatus;
|
||||||
|
}
|
||||||
229
apps/api/src/terminal/terminal-session.service.spec.ts
Normal file
229
apps/api/src/terminal/terminal-session.service.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* TerminalSessionService Tests
|
||||||
|
*
|
||||||
|
* Unit tests for database-backed terminal session CRUD:
|
||||||
|
* create, findByWorkspace, close, and findById.
|
||||||
|
* PrismaService is mocked to isolate the service logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { TerminalSessionStatus } from "@prisma/client";
|
||||||
|
import type { TerminalSession } from "@prisma/client";
|
||||||
|
import { TerminalSessionService } from "./terminal-session.service";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function makeSession(overrides: Partial<TerminalSession> = {}): TerminalSession {
|
||||||
|
return {
|
||||||
|
id: "session-uuid-1",
|
||||||
|
workspaceId: "workspace-uuid-1",
|
||||||
|
name: "Terminal",
|
||||||
|
status: TerminalSessionStatus.ACTIVE,
|
||||||
|
createdAt: new Date("2026-02-25T00:00:00Z"),
|
||||||
|
closedAt: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mock PrismaService
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function makeMockPrisma() {
|
||||||
|
return {
|
||||||
|
terminalSession: {
|
||||||
|
create: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tests
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("TerminalSessionService", () => {
|
||||||
|
let service: TerminalSessionService;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let mockPrisma: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockPrisma = makeMockPrisma();
|
||||||
|
service = new TerminalSessionService(mockPrisma);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// create
|
||||||
|
// ==========================================
|
||||||
|
describe("create", () => {
|
||||||
|
it("should call prisma.terminalSession.create with workspaceId only when no name provided", async () => {
|
||||||
|
const session = makeSession();
|
||||||
|
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||||
|
|
||||||
|
const result = await service.create("workspace-uuid-1");
|
||||||
|
|
||||||
|
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
|
||||||
|
data: { workspaceId: "workspace-uuid-1" },
|
||||||
|
});
|
||||||
|
expect(result).toEqual(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include name in create data when name is provided", async () => {
|
||||||
|
const session = makeSession({ name: "My Terminal" });
|
||||||
|
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||||
|
|
||||||
|
const result = await service.create("workspace-uuid-1", "My Terminal");
|
||||||
|
|
||||||
|
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
|
||||||
|
data: { workspaceId: "workspace-uuid-1", name: "My Terminal" },
|
||||||
|
});
|
||||||
|
expect(result).toEqual(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the created session", async () => {
|
||||||
|
const session = makeSession();
|
||||||
|
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||||
|
|
||||||
|
const result = await service.create("workspace-uuid-1");
|
||||||
|
|
||||||
|
expect(result.id).toBe("session-uuid-1");
|
||||||
|
expect(result.status).toBe(TerminalSessionStatus.ACTIVE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// findByWorkspace
|
||||||
|
// ==========================================
|
||||||
|
describe("findByWorkspace", () => {
|
||||||
|
it("should query for ACTIVE sessions in the given workspace, ordered by createdAt desc", async () => {
|
||||||
|
const sessions = [makeSession(), makeSession({ id: "session-uuid-2" })];
|
||||||
|
mockPrisma.terminalSession.findMany.mockResolvedValueOnce(sessions);
|
||||||
|
|
||||||
|
const result = await service.findByWorkspace("workspace-uuid-1");
|
||||||
|
|
||||||
|
expect(mockPrisma.terminalSession.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
workspaceId: "workspace-uuid-1",
|
||||||
|
status: TerminalSessionStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return an empty array when no active sessions exist", async () => {
|
||||||
|
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const result = await service.findByWorkspace("workspace-uuid-empty");
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not include CLOSED sessions", async () => {
|
||||||
|
// The where clause enforces ACTIVE status — verify it is present
|
||||||
|
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await service.findByWorkspace("workspace-uuid-1");
|
||||||
|
|
||||||
|
const callArgs = mockPrisma.terminalSession.findMany.mock.calls[0][0] as {
|
||||||
|
where: { status: TerminalSessionStatus };
|
||||||
|
};
|
||||||
|
expect(callArgs.where.status).toBe(TerminalSessionStatus.ACTIVE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// close
|
||||||
|
// ==========================================
|
||||||
|
describe("close", () => {
|
||||||
|
it("should set status to CLOSED and set closedAt when session exists", async () => {
|
||||||
|
const existingSession = makeSession();
|
||||||
|
const closedSession = makeSession({
|
||||||
|
status: TerminalSessionStatus.CLOSED,
|
||||||
|
closedAt: new Date("2026-02-25T01:00:00Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
|
||||||
|
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
|
||||||
|
|
||||||
|
const result = await service.close("session-uuid-1");
|
||||||
|
|
||||||
|
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: "session-uuid-1" },
|
||||||
|
});
|
||||||
|
expect(mockPrisma.terminalSession.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: "session-uuid-1" },
|
||||||
|
data: {
|
||||||
|
status: TerminalSessionStatus.CLOSED,
|
||||||
|
closedAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(TerminalSessionStatus.CLOSED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when session does not exist", async () => {
|
||||||
|
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
await expect(service.close("nonexistent-id")).rejects.toThrow(NotFoundException);
|
||||||
|
expect(mockPrisma.terminalSession.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include a non-null closedAt timestamp on close", async () => {
|
||||||
|
const existingSession = makeSession();
|
||||||
|
const closedSession = makeSession({
|
||||||
|
status: TerminalSessionStatus.CLOSED,
|
||||||
|
closedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
|
||||||
|
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
|
||||||
|
|
||||||
|
const result = await service.close("session-uuid-1");
|
||||||
|
|
||||||
|
expect(result.closedAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// findById
|
||||||
|
// ==========================================
|
||||||
|
describe("findById", () => {
|
||||||
|
it("should return the session when it exists", async () => {
|
||||||
|
const session = makeSession();
|
||||||
|
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(session);
|
||||||
|
|
||||||
|
const result = await service.findById("session-uuid-1");
|
||||||
|
|
||||||
|
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: "session-uuid-1" },
|
||||||
|
});
|
||||||
|
expect(result).toEqual(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when session does not exist", async () => {
|
||||||
|
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const result = await service.findById("no-such-id");
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find CLOSED sessions as well as ACTIVE ones", async () => {
|
||||||
|
const closedSession = makeSession({
|
||||||
|
status: TerminalSessionStatus.CLOSED,
|
||||||
|
closedAt: new Date(),
|
||||||
|
});
|
||||||
|
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(closedSession);
|
||||||
|
|
||||||
|
const result = await service.findById("session-uuid-1");
|
||||||
|
|
||||||
|
expect(result?.status).toBe(TerminalSessionStatus.CLOSED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
96
apps/api/src/terminal/terminal-session.service.ts
Normal file
96
apps/api/src/terminal/terminal-session.service.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* TerminalSessionService
|
||||||
|
*
|
||||||
|
* Manages database persistence for terminal sessions.
|
||||||
|
* Provides CRUD operations on the TerminalSession model,
|
||||||
|
* enabling session tracking, recovery, and workspace-level listing.
|
||||||
|
*
|
||||||
|
* Session lifecycle:
|
||||||
|
* - create: record a new terminal session with ACTIVE status
|
||||||
|
* - findByWorkspace: return all ACTIVE sessions for a workspace
|
||||||
|
* - close: mark a session as CLOSED, set closedAt timestamp
|
||||||
|
* - findById: retrieve a single session by ID
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
|
||||||
|
import { TerminalSessionStatus } from "@prisma/client";
|
||||||
|
import type { TerminalSession } from "@prisma/client";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TerminalSessionService {
|
||||||
|
private readonly logger = new Logger(TerminalSessionService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new terminal session record in the database.
|
||||||
|
*
|
||||||
|
* @param workspaceId - The workspace this session belongs to
|
||||||
|
* @param name - Optional display name for the session (defaults to "Terminal")
|
||||||
|
* @returns The created TerminalSession record
|
||||||
|
*/
|
||||||
|
async create(workspaceId: string, name?: string): Promise<TerminalSession> {
|
||||||
|
this.logger.log(
|
||||||
|
`Creating terminal session for workspace ${workspaceId}${name !== undefined ? ` (name: ${name})` : ""}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const data: { workspaceId: string; name?: string } = { workspaceId };
|
||||||
|
if (name !== undefined) {
|
||||||
|
data.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.terminalSession.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all ACTIVE terminal sessions for a workspace.
|
||||||
|
*
|
||||||
|
* @param workspaceId - The workspace to query
|
||||||
|
* @returns Array of active TerminalSession records, ordered by creation time (newest first)
|
||||||
|
*/
|
||||||
|
async findByWorkspace(workspaceId: string): Promise<TerminalSession[]> {
|
||||||
|
return this.prisma.terminalSession.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: TerminalSessionStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close a terminal session by setting its status to CLOSED and recording closedAt.
|
||||||
|
*
|
||||||
|
* @param id - The session ID to close
|
||||||
|
* @returns The updated TerminalSession record
|
||||||
|
* @throws NotFoundException if the session does not exist
|
||||||
|
*/
|
||||||
|
async close(id: string): Promise<TerminalSession> {
|
||||||
|
const existing = await this.prisma.terminalSession.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException(`Terminal session ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Closing terminal session ${id} (workspace: ${existing.workspaceId})`);
|
||||||
|
|
||||||
|
return this.prisma.terminalSession.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: TerminalSessionStatus.CLOSED,
|
||||||
|
closedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a terminal session by ID.
|
||||||
|
*
|
||||||
|
* @param id - The session ID to retrieve
|
||||||
|
* @returns The TerminalSession record, or null if not found
|
||||||
|
*/
|
||||||
|
async findById(id: string): Promise<TerminalSession | null> {
|
||||||
|
return this.prisma.terminalSession.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
89
apps/api/src/terminal/terminal.dto.ts
Normal file
89
apps/api/src/terminal/terminal.dto.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Terminal DTOs
|
||||||
|
*
|
||||||
|
* Data Transfer Objects for terminal WebSocket events.
|
||||||
|
* Validated using class-validator decorators.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsNumber,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a new terminal PTY session.
|
||||||
|
*/
|
||||||
|
export class CreateTerminalDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(500)
|
||||||
|
cols?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
|
rows?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4096)
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for sending input data to a terminal PTY session.
|
||||||
|
*/
|
||||||
|
export class TerminalInputDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(64)
|
||||||
|
sessionId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
data!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for resizing a terminal PTY session.
|
||||||
|
*/
|
||||||
|
export class TerminalResizeDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(64)
|
||||||
|
sessionId!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(500)
|
||||||
|
cols!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
|
rows!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for closing a terminal PTY session.
|
||||||
|
*/
|
||||||
|
export class CloseTerminalDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(64)
|
||||||
|
sessionId!: string;
|
||||||
|
}
|
||||||
501
apps/api/src/terminal/terminal.gateway.spec.ts
Normal file
501
apps/api/src/terminal/terminal.gateway.spec.ts
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
/**
|
||||||
|
* TerminalGateway Tests
|
||||||
|
*
|
||||||
|
* Unit tests for WebSocket terminal gateway:
|
||||||
|
* - Authentication on connection
|
||||||
|
* - terminal:create event handling
|
||||||
|
* - terminal:input event handling
|
||||||
|
* - terminal:resize event handling
|
||||||
|
* - terminal:close event handling
|
||||||
|
* - disconnect cleanup
|
||||||
|
* - Error paths
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||||
|
import type { Socket } from "socket.io";
|
||||||
|
import { TerminalGateway } from "./terminal.gateway";
|
||||||
|
import { TerminalService } from "./terminal.service";
|
||||||
|
import { AuthService } from "../auth/auth.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mocks
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Mock node-pty globally so TerminalService doesn't fail to import
|
||||||
|
vi.mock("node-pty", () => ({
|
||||||
|
spawn: vi.fn(() => ({
|
||||||
|
onData: vi.fn(),
|
||||||
|
onExit: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
resize: vi.fn(),
|
||||||
|
kill: vi.fn(),
|
||||||
|
pid: 1000,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface AuthenticatedSocket extends Socket {
|
||||||
|
data: {
|
||||||
|
userId?: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockSocket(id = "test-socket-id"): AuthenticatedSocket {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
emit: vi.fn(),
|
||||||
|
join: vi.fn(),
|
||||||
|
leave: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
data: {},
|
||||||
|
handshake: {
|
||||||
|
auth: { token: "valid-token" },
|
||||||
|
query: {},
|
||||||
|
headers: {},
|
||||||
|
},
|
||||||
|
} as unknown as AuthenticatedSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockAuthService() {
|
||||||
|
return {
|
||||||
|
verifySession: vi.fn().mockResolvedValue({
|
||||||
|
user: { id: "user-123" },
|
||||||
|
session: { id: "session-123" },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockPrismaService() {
|
||||||
|
return {
|
||||||
|
workspaceMember: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockTerminalService() {
|
||||||
|
return {
|
||||||
|
createSession: vi.fn().mockReturnValue({
|
||||||
|
sessionId: "session-uuid-1",
|
||||||
|
name: undefined,
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
}),
|
||||||
|
writeToSession: vi.fn(),
|
||||||
|
resizeSession: vi.fn(),
|
||||||
|
closeSession: vi.fn().mockReturnValue(true),
|
||||||
|
closeWorkspaceSessions: vi.fn(),
|
||||||
|
sessionBelongsToWorkspace: vi.fn().mockReturnValue(true),
|
||||||
|
getWorkspaceSessionCount: vi.fn().mockReturnValue(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tests
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("TerminalGateway", () => {
|
||||||
|
let gateway: TerminalGateway;
|
||||||
|
let mockAuthService: ReturnType<typeof createMockAuthService>;
|
||||||
|
let mockPrismaService: ReturnType<typeof createMockPrismaService>;
|
||||||
|
let mockTerminalService: ReturnType<typeof createMockTerminalService>;
|
||||||
|
let mockClient: AuthenticatedSocket;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockAuthService = createMockAuthService();
|
||||||
|
mockPrismaService = createMockPrismaService();
|
||||||
|
mockTerminalService = createMockTerminalService();
|
||||||
|
mockClient = createMockSocket();
|
||||||
|
|
||||||
|
gateway = new TerminalGateway(
|
||||||
|
mockAuthService as unknown as AuthService,
|
||||||
|
mockPrismaService as unknown as PrismaService,
|
||||||
|
mockTerminalService as unknown as TerminalService
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// handleConnection (authentication)
|
||||||
|
// ==========================================
|
||||||
|
describe("handleConnection", () => {
|
||||||
|
it("should authenticate client and join workspace room on valid token", async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({
|
||||||
|
user: { id: "user-123" },
|
||||||
|
});
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
|
||||||
|
expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token");
|
||||||
|
expect(mockClient.data.userId).toBe("user-123");
|
||||||
|
expect(mockClient.data.workspaceId).toBe("workspace-456");
|
||||||
|
expect(mockClient.join).toHaveBeenCalledWith("terminal:workspace-456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disconnect and emit error if no token provided", async () => {
|
||||||
|
const clientNoToken = createMockSocket("no-token");
|
||||||
|
clientNoToken.handshake = {
|
||||||
|
auth: {},
|
||||||
|
query: {},
|
||||||
|
headers: {},
|
||||||
|
} as typeof clientNoToken.handshake;
|
||||||
|
|
||||||
|
await gateway.handleConnection(clientNoToken);
|
||||||
|
|
||||||
|
expect(clientNoToken.disconnect).toHaveBeenCalled();
|
||||||
|
expect(clientNoToken.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("no token") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disconnect and emit error if token is invalid", async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
|
||||||
|
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("invalid") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disconnect and emit error if no workspace access", async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
|
||||||
|
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("workspace") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disconnect and emit error if auth throws", async () => {
|
||||||
|
mockAuthService.verifySession.mockRejectedValue(new Error("Auth service down"));
|
||||||
|
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
|
||||||
|
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.any(String) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract token from handshake.query as fallback", async () => {
|
||||||
|
const clientQueryToken = createMockSocket("query-token-client");
|
||||||
|
clientQueryToken.handshake = {
|
||||||
|
auth: {},
|
||||||
|
query: { token: "query-token" },
|
||||||
|
headers: {},
|
||||||
|
} as typeof clientQueryToken.handshake;
|
||||||
|
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
|
||||||
|
await gateway.handleConnection(clientQueryToken);
|
||||||
|
|
||||||
|
expect(mockAuthService.verifySession).toHaveBeenCalledWith("query-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract token from Authorization header as last fallback", async () => {
|
||||||
|
const clientHeaderToken = createMockSocket("header-token-client");
|
||||||
|
clientHeaderToken.handshake = {
|
||||||
|
auth: {},
|
||||||
|
query: {},
|
||||||
|
headers: { authorization: "Bearer header-token" },
|
||||||
|
} as typeof clientHeaderToken.handshake;
|
||||||
|
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
|
||||||
|
await gateway.handleConnection(clientHeaderToken);
|
||||||
|
|
||||||
|
expect(mockAuthService.verifySession).toHaveBeenCalledWith("header-token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// handleDisconnect
|
||||||
|
// ==========================================
|
||||||
|
describe("handleDisconnect", () => {
|
||||||
|
it("should close all workspace sessions on disconnect", async () => {
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
gateway.handleDisconnect(mockClient);
|
||||||
|
|
||||||
|
expect(mockTerminalService.closeWorkspaceSessions).toHaveBeenCalledWith("workspace-456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw for unauthenticated client disconnect", () => {
|
||||||
|
const unauthClient = createMockSocket("unauth-disconnect");
|
||||||
|
|
||||||
|
expect(() => gateway.handleDisconnect(unauthClient)).not.toThrow();
|
||||||
|
expect(mockTerminalService.closeWorkspaceSessions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// handleCreate (terminal:create)
|
||||||
|
// ==========================================
|
||||||
|
describe("handleCreate", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a PTY session and emit terminal:created", async () => {
|
||||||
|
mockTerminalService.createSession.mockReturnValue({
|
||||||
|
sessionId: "new-session-id",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
await gateway.handleCreate(mockClient, {});
|
||||||
|
|
||||||
|
expect(mockTerminalService.createSession).toHaveBeenCalled();
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:created",
|
||||||
|
expect.objectContaining({ sessionId: "new-session-id" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass cols, rows, cwd, name to service", async () => {
|
||||||
|
await gateway.handleCreate(mockClient, {
|
||||||
|
cols: 132,
|
||||||
|
rows: 50,
|
||||||
|
cwd: "/home/user",
|
||||||
|
name: "my-shell",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTerminalService.createSession).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ cols: 132, rows: 50, cwd: "/home/user", name: "my-shell" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if not authenticated", async () => {
|
||||||
|
const unauthClient = createMockSocket("unauth");
|
||||||
|
|
||||||
|
await gateway.handleCreate(unauthClient, {});
|
||||||
|
|
||||||
|
expect(unauthClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("authenticated") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if service throws (session limit)", async () => {
|
||||||
|
mockTerminalService.createSession.mockImplementation(() => {
|
||||||
|
throw new Error("Workspace has reached the maximum of 10 concurrent terminal sessions");
|
||||||
|
});
|
||||||
|
|
||||||
|
await gateway.handleCreate(mockClient, {});
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("maximum") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error for invalid payload (negative cols)", async () => {
|
||||||
|
await gateway.handleCreate(mockClient, { cols: -1 });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// handleInput (terminal:input)
|
||||||
|
// ==========================================
|
||||||
|
describe("handleInput", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should write data to the PTY session", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||||
|
|
||||||
|
await gateway.handleInput(mockClient, { sessionId: "sess-1", data: "ls\n" });
|
||||||
|
|
||||||
|
expect(mockTerminalService.writeToSession).toHaveBeenCalledWith("sess-1", "ls\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if session does not belong to workspace", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
||||||
|
|
||||||
|
await gateway.handleInput(mockClient, { sessionId: "alien-sess", data: "data" });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||||
|
);
|
||||||
|
expect(mockTerminalService.writeToSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if not authenticated", async () => {
|
||||||
|
const unauthClient = createMockSocket("unauth");
|
||||||
|
|
||||||
|
await gateway.handleInput(unauthClient, { sessionId: "sess-1", data: "x" });
|
||||||
|
|
||||||
|
expect(unauthClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("authenticated") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
|
||||||
|
await gateway.handleInput(mockClient, { data: "some input" });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// handleResize (terminal:resize)
|
||||||
|
// ==========================================
|
||||||
|
describe("handleResize", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resize the PTY session", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||||
|
|
||||||
|
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 120, rows: 40 });
|
||||||
|
|
||||||
|
expect(mockTerminalService.resizeSession).toHaveBeenCalledWith("sess-1", 120, 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if session does not belong to workspace", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
||||||
|
|
||||||
|
await gateway.handleResize(mockClient, { sessionId: "alien-sess", cols: 80, rows: 24 });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error for invalid payload (cols too large)", async () => {
|
||||||
|
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 9999, rows: 24 });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// handleClose (terminal:close)
|
||||||
|
// ==========================================
|
||||||
|
describe("handleClose", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should close an existing PTY session", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||||
|
mockTerminalService.closeSession.mockReturnValue(true);
|
||||||
|
|
||||||
|
await gateway.handleClose(mockClient, { sessionId: "sess-1" });
|
||||||
|
|
||||||
|
expect(mockTerminalService.closeSession).toHaveBeenCalledWith("sess-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if session does not belong to workspace", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
||||||
|
|
||||||
|
await gateway.handleClose(mockClient, { sessionId: "alien-sess" });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if closeSession returns false (session gone)", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||||
|
mockTerminalService.closeSession.mockReturnValue(false);
|
||||||
|
|
||||||
|
await gateway.handleClose(mockClient, { sessionId: "gone-sess" });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
|
||||||
|
await gateway.handleClose(mockClient, {});
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
423
apps/api/src/terminal/terminal.gateway.ts
Normal file
423
apps/api/src/terminal/terminal.gateway.ts
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
/**
|
||||||
|
* TerminalGateway
|
||||||
|
*
|
||||||
|
* WebSocket gateway for real-time PTY terminal sessions.
|
||||||
|
* Uses the `/terminal` namespace to keep terminal traffic separate
|
||||||
|
* from the main WebSocket gateway.
|
||||||
|
*
|
||||||
|
* Protocol:
|
||||||
|
* 1. Client connects with auth token in handshake
|
||||||
|
* 2. Client emits `terminal:create` to spawn a new PTY session
|
||||||
|
* 3. Server emits `terminal:created` with { sessionId }
|
||||||
|
* 4. Client emits `terminal:input` with { sessionId, data } to send keystrokes
|
||||||
|
* 5. Server emits `terminal:output` with { sessionId, data } for stdout/stderr
|
||||||
|
* 6. Client emits `terminal:resize` with { sessionId, cols, rows } on window resize
|
||||||
|
* 7. Client emits `terminal:close` with { sessionId } to terminate the PTY
|
||||||
|
* 8. Server emits `terminal:exit` with { sessionId, exitCode, signal } on PTY exit
|
||||||
|
*
|
||||||
|
* Authentication:
|
||||||
|
* - Same pattern as websocket.gateway.ts and speech.gateway.ts
|
||||||
|
* - Token extracted from handshake.auth.token / query.token / Authorization header
|
||||||
|
*
|
||||||
|
* Workspace isolation:
|
||||||
|
* - Clients join room `terminal:{workspaceId}` on connect
|
||||||
|
* - Sessions are scoped to workspace; cross-workspace access is denied
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
WebSocketGateway as WSGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
SubscribeMessage,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
} from "@nestjs/websockets";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import { Server, Socket } from "socket.io";
|
||||||
|
import { AuthService } from "../auth/auth.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { TerminalService } from "./terminal.service";
|
||||||
|
import {
|
||||||
|
CreateTerminalDto,
|
||||||
|
TerminalInputDto,
|
||||||
|
TerminalResizeDto,
|
||||||
|
CloseTerminalDto,
|
||||||
|
} from "./terminal.dto";
|
||||||
|
import { validate } from "class-validator";
|
||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Types
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
interface AuthenticatedSocket extends Socket {
|
||||||
|
data: {
|
||||||
|
userId?: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Gateway
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
@WSGateway({
|
||||||
|
namespace: "/terminal",
|
||||||
|
cors: {
|
||||||
|
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server!: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(TerminalGateway.name);
|
||||||
|
private readonly CONNECTION_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly terminalService: TerminalService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Connection lifecycle
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate client on connection using handshake token.
|
||||||
|
* Validates workspace membership and joins the workspace-scoped room.
|
||||||
|
*/
|
||||||
|
async handleConnection(client: Socket): Promise<void> {
|
||||||
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (!authenticatedClient.data.userId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Terminal client ${authenticatedClient.id} timed out during authentication`
|
||||||
|
);
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Authentication timed out.",
|
||||||
|
});
|
||||||
|
authenticatedClient.disconnect();
|
||||||
|
}
|
||||||
|
}, this.CONNECTION_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = this.extractTokenFromHandshake(authenticatedClient);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.logger.warn(`Terminal client ${authenticatedClient.id} connected without token`);
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Authentication failed: no token provided.",
|
||||||
|
});
|
||||||
|
authenticatedClient.disconnect();
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = await this.authService.verifySession(token);
|
||||||
|
|
||||||
|
if (!sessionData) {
|
||||||
|
this.logger.warn(`Terminal client ${authenticatedClient.id} has invalid token`);
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Authentication failed: invalid or expired token.",
|
||||||
|
});
|
||||||
|
authenticatedClient.disconnect();
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = sessionData.user as { id: string };
|
||||||
|
const userId = user.id;
|
||||||
|
|
||||||
|
const workspaceMembership = await this.prisma.workspaceMember.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
select: { workspaceId: true, userId: true, role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspaceMembership) {
|
||||||
|
this.logger.warn(`Terminal user ${userId} has no workspace access`);
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Authentication failed: no workspace access.",
|
||||||
|
});
|
||||||
|
authenticatedClient.disconnect();
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticatedClient.data.userId = userId;
|
||||||
|
authenticatedClient.data.workspaceId = workspaceMembership.workspaceId;
|
||||||
|
|
||||||
|
// Join workspace-scoped terminal room
|
||||||
|
const room = this.getWorkspaceRoom(workspaceMembership.workspaceId);
|
||||||
|
await authenticatedClient.join(room);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.logger.log(
|
||||||
|
`Terminal client ${authenticatedClient.id} connected (user: ${userId}, workspace: ${workspaceMembership.workspaceId})`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.logger.error(
|
||||||
|
`Authentication failed for terminal client ${authenticatedClient.id}:`,
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
);
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Authentication failed: an unexpected error occurred.",
|
||||||
|
});
|
||||||
|
authenticatedClient.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all PTY sessions for this client's workspace on disconnect.
|
||||||
|
*/
|
||||||
|
handleDisconnect(client: Socket): void {
|
||||||
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
|
const { workspaceId, userId } = authenticatedClient.data;
|
||||||
|
|
||||||
|
if (workspaceId) {
|
||||||
|
this.terminalService.closeWorkspaceSessions(workspaceId);
|
||||||
|
|
||||||
|
const room = this.getWorkspaceRoom(workspaceId);
|
||||||
|
void authenticatedClient.leave(room);
|
||||||
|
this.logger.log(
|
||||||
|
`Terminal client ${authenticatedClient.id} disconnected (user: ${userId ?? "unknown"}, workspace: ${workspaceId})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.debug(`Terminal client ${authenticatedClient.id} disconnected (unauthenticated)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Terminal events
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn a new PTY session for the connected client.
|
||||||
|
*
|
||||||
|
* Emits `terminal:created` with { sessionId, name, cols, rows } on success.
|
||||||
|
* Emits `terminal:error` on failure.
|
||||||
|
*/
|
||||||
|
@SubscribeMessage("terminal:create")
|
||||||
|
async handleCreate(client: Socket, payload: unknown): Promise<void> {
|
||||||
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
|
const { userId, workspaceId } = authenticatedClient.data;
|
||||||
|
|
||||||
|
if (!userId || !workspaceId) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Not authenticated. Connect with a valid token.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate DTO
|
||||||
|
const dto = plainToInstance(CreateTerminalDto, payload ?? {});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Invalid payload: ${messages}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = this.terminalService.createSession(authenticatedClient, {
|
||||||
|
workspaceId,
|
||||||
|
socketId: authenticatedClient.id,
|
||||||
|
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||||
|
...(dto.cols !== undefined ? { cols: dto.cols } : {}),
|
||||||
|
...(dto.rows !== undefined ? { rows: dto.rows } : {}),
|
||||||
|
...(dto.cwd !== undefined ? { cwd: dto.cwd } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
authenticatedClient.emit("terminal:created", {
|
||||||
|
sessionId: result.sessionId,
|
||||||
|
name: result.name,
|
||||||
|
cols: result.cols,
|
||||||
|
rows: result.rows,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Terminal session ${result.sessionId} created for client ${authenticatedClient.id} (workspace: ${workspaceId})`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to create terminal session for client ${authenticatedClient.id}: ${message}`
|
||||||
|
);
|
||||||
|
authenticatedClient.emit("terminal:error", { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write input data to an existing PTY session.
|
||||||
|
*
|
||||||
|
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||||
|
*/
|
||||||
|
@SubscribeMessage("terminal:input")
|
||||||
|
async handleInput(client: Socket, payload: unknown): Promise<void> {
|
||||||
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
|
const { userId, workspaceId } = authenticatedClient.data;
|
||||||
|
|
||||||
|
if (!userId || !workspaceId) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Not authenticated. Connect with a valid token.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = plainToInstance(TerminalInputDto, payload ?? {});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Invalid payload: ${messages}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.terminalService.writeToSession(dto.sessionId, dto.data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(`Failed to write to terminal session ${dto.sessionId}: ${message}`);
|
||||||
|
authenticatedClient.emit("terminal:error", { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize an existing PTY session.
|
||||||
|
*
|
||||||
|
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||||
|
*/
|
||||||
|
@SubscribeMessage("terminal:resize")
|
||||||
|
async handleResize(client: Socket, payload: unknown): Promise<void> {
|
||||||
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
|
const { userId, workspaceId } = authenticatedClient.data;
|
||||||
|
|
||||||
|
if (!userId || !workspaceId) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Not authenticated. Connect with a valid token.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = plainToInstance(TerminalResizeDto, payload ?? {});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Invalid payload: ${messages}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.terminalService.resizeSession(dto.sessionId, dto.cols, dto.rows);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(`Failed to resize terminal session ${dto.sessionId}: ${message}`);
|
||||||
|
authenticatedClient.emit("terminal:error", { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill and close an existing PTY session.
|
||||||
|
*
|
||||||
|
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||||
|
*/
|
||||||
|
@SubscribeMessage("terminal:close")
|
||||||
|
async handleClose(client: Socket, payload: unknown): Promise<void> {
|
||||||
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
|
const { userId, workspaceId } = authenticatedClient.data;
|
||||||
|
|
||||||
|
if (!userId || !workspaceId) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Not authenticated. Connect with a valid token.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = plainToInstance(CloseTerminalDto, payload ?? {});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Invalid payload: ${messages}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closed = this.terminalService.closeSession(dto.sessionId);
|
||||||
|
if (!closed) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Terminal session ${dto.sessionId} not found.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Terminal session ${dto.sessionId} closed by client ${authenticatedClient.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Private helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract authentication token from Socket.IO handshake.
|
||||||
|
* Checks auth.token, query.token, and Authorization header (in that order).
|
||||||
|
*/
|
||||||
|
private extractTokenFromHandshake(client: Socket): string | undefined {
|
||||||
|
const authToken = client.handshake.auth.token as unknown;
|
||||||
|
if (typeof authToken === "string" && authToken.length > 0) {
|
||||||
|
return authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryToken = client.handshake.query.token as unknown;
|
||||||
|
if (typeof queryToken === "string" && queryToken.length > 0) {
|
||||||
|
return queryToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = client.handshake.headers.authorization as unknown;
|
||||||
|
if (typeof authHeader === "string") {
|
||||||
|
const parts = authHeader.split(" ");
|
||||||
|
const [type, token] = parts;
|
||||||
|
if (type === "Bearer" && token) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the workspace-scoped room name for the terminal namespace.
|
||||||
|
*/
|
||||||
|
private getWorkspaceRoom(workspaceId: string): string {
|
||||||
|
return `terminal:${workspaceId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/api/src/terminal/terminal.module.ts
Normal file
31
apps/api/src/terminal/terminal.module.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* TerminalModule
|
||||||
|
*
|
||||||
|
* NestJS module for WebSocket-based terminal sessions via node-pty.
|
||||||
|
*
|
||||||
|
* Imports:
|
||||||
|
* - AuthModule for WebSocket authentication (verifySession)
|
||||||
|
* - PrismaModule for workspace membership queries and session persistence
|
||||||
|
*
|
||||||
|
* Providers:
|
||||||
|
* - TerminalService: manages PTY session lifecycle (in-memory)
|
||||||
|
* - TerminalSessionService: persists session records to the database
|
||||||
|
* - TerminalGateway: WebSocket gateway on /terminal namespace
|
||||||
|
*
|
||||||
|
* The module does not export providers; terminal sessions are
|
||||||
|
* self-contained within this module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TerminalGateway } from "./terminal.gateway";
|
||||||
|
import { TerminalService } from "./terminal.service";
|
||||||
|
import { TerminalSessionService } from "./terminal-session.service";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, PrismaModule],
|
||||||
|
providers: [TerminalGateway, TerminalService, TerminalSessionService],
|
||||||
|
exports: [TerminalSessionService],
|
||||||
|
})
|
||||||
|
export class TerminalModule {}
|
||||||
337
apps/api/src/terminal/terminal.service.spec.ts
Normal file
337
apps/api/src/terminal/terminal.service.spec.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* TerminalService Tests
|
||||||
|
*
|
||||||
|
* Unit tests for PTY session management: create, write, resize, close,
|
||||||
|
* workspace cleanup, and access control.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||||
|
import type { Socket } from "socket.io";
|
||||||
|
import { TerminalService, MAX_SESSIONS_PER_WORKSPACE } from "./terminal.service";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mocks
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Mock node-pty before importing service
|
||||||
|
const mockPtyProcess = {
|
||||||
|
onData: vi.fn(),
|
||||||
|
onExit: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
resize: vi.fn(),
|
||||||
|
kill: vi.fn(),
|
||||||
|
pid: 12345,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("node-pty", () => ({
|
||||||
|
spawn: vi.fn(() => mockPtyProcess),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createMockSocket(id = "socket-1"): Socket {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
emit: vi.fn(),
|
||||||
|
join: vi.fn(),
|
||||||
|
leave: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
data: {},
|
||||||
|
} as unknown as Socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tests
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("TerminalService", () => {
|
||||||
|
let service: TerminalService;
|
||||||
|
let mockSocket: Socket;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Reset mock implementations
|
||||||
|
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
|
||||||
|
mockPtyProcess.onExit.mockImplementation(
|
||||||
|
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
|
||||||
|
);
|
||||||
|
service = new TerminalService();
|
||||||
|
mockSocket = createMockSocket();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// createSession
|
||||||
|
// ==========================================
|
||||||
|
describe("createSession", () => {
|
||||||
|
it("should create a PTY session and return sessionId", () => {
|
||||||
|
const result = service.createSession(mockSocket, {
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
socketId: "socket-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.sessionId).toBeDefined();
|
||||||
|
expect(typeof result.sessionId).toBe("string");
|
||||||
|
expect(result.cols).toBe(80);
|
||||||
|
expect(result.rows).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use provided cols and rows", () => {
|
||||||
|
const result = service.createSession(mockSocket, {
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
socketId: "socket-1",
|
||||||
|
cols: 120,
|
||||||
|
rows: 40,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.cols).toBe(120);
|
||||||
|
expect(result.rows).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the provided session name", () => {
|
||||||
|
const result = service.createSession(mockSocket, {
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
socketId: "socket-1",
|
||||||
|
name: "my-terminal",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.name).toBe("my-terminal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should wire PTY onData to emit terminal:output", () => {
|
||||||
|
let dataCallback: ((data: string) => void) | undefined;
|
||||||
|
mockPtyProcess.onData.mockImplementation((cb: (data: string) => void) => {
|
||||||
|
dataCallback = cb;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.createSession(mockSocket, {
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
socketId: "socket-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dataCallback).toBeDefined();
|
||||||
|
dataCallback!("hello world");
|
||||||
|
|
||||||
|
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:output", {
|
||||||
|
sessionId: result.sessionId,
|
||||||
|
data: "hello world",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should wire PTY onExit to emit terminal:exit and cleanup", () => {
|
||||||
|
let exitCallback: ((e: { exitCode: number; signal?: number }) => void) | undefined;
|
||||||
|
mockPtyProcess.onExit.mockImplementation(
|
||||||
|
(cb: (e: { exitCode: number; signal?: number }) => void) => {
|
||||||
|
exitCallback = cb;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = service.createSession(mockSocket, {
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
socketId: "socket-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCallback).toBeDefined();
|
||||||
|
exitCallback!({ exitCode: 0 });
|
||||||
|
|
||||||
|
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:exit", {
|
||||||
|
sessionId: result.sessionId,
|
||||||
|
exitCode: 0,
|
||||||
|
signal: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Session should be cleaned up
|
||||||
|
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false);
|
||||||
|
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when workspace session limit is reached", () => {
|
||||||
|
const limit = MAX_SESSIONS_PER_WORKSPACE;
|
||||||
|
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
service.createSession(createMockSocket(`socket-${String(i)}`), {
|
||||||
|
workspaceId: "ws-limit",
|
||||||
|
socketId: `socket-${String(i)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
service.createSession(createMockSocket("socket-overflow"), {
|
||||||
|
workspaceId: "ws-limit",
|
||||||
|
socketId: "socket-overflow",
|
||||||
|
})
|
||||||
|
).toThrow(/maximum/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow sessions in different workspaces independently", () => {
|
||||||
|
service.createSession(mockSocket, { workspaceId: "ws-a", socketId: "s1" });
|
||||||
|
service.createSession(createMockSocket("s2"), { workspaceId: "ws-b", socketId: "s2" });
|
||||||
|
|
||||||
|
expect(service.getWorkspaceSessionCount("ws-a")).toBe(1);
|
||||||
|
expect(service.getWorkspaceSessionCount("ws-b")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// writeToSession
|
||||||
|
// ==========================================
|
||||||
|
describe("writeToSession", () => {
|
||||||
|
it("should write data to PTY", () => {
|
||||||
|
const result = service.createSession(mockSocket, {
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
socketId: "socket-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
service.writeToSession(result.sessionId, "ls -la\n");
|
||||||
|
|
||||||
|
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw for unknown sessionId", () => {
|
||||||
|
expect(() => service.writeToSession("nonexistent-id", "data")).toThrow(/not found/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// resizeSession
|
||||||
|
// ==========================================
|
||||||
|
describe("resizeSession", () => {
|
||||||
|
it("should resize PTY dimensions", () => {
|
||||||
|
const result = service.createSession(mockSocket, {
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
socketId: "socket-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
service.resizeSession(result.sessionId, 132, 50);
|
||||||
|
|
||||||
|
expect(mockPtyProcess.resize).toHaveBeenCalledWith(132, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw for unknown sessionId", () => {
|
||||||
|
expect(() => service.resizeSession("nonexistent-id", 80, 24)).toThrow(/not found/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// closeSession
|
||||||
|
// ==========================================
|
||||||
|
describe("closeSession", () => {
|
||||||
|
it("should kill PTY and return true for existing session", () => {
|
||||||
|
const result = service.createSession(mockSocket, {
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
socketId: "socket-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const closed = service.closeSession(result.sessionId);
|
||||||
|
|
||||||
|
expect(closed).toBe(true);
|
||||||
|
expect(mockPtyProcess.kill).toHaveBeenCalled();
|
||||||
|
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for nonexistent sessionId", () => {
|
||||||
|
const closed = service.closeSession("does-not-exist");
|
||||||
|
expect(closed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clean up workspace tracking after close", () => {
|
||||||
|
const result = service.createSession(mockSocket, {
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
socketId: "socket-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.getWorkspaceSessionCount("ws-1")).toBe(1);
|
||||||
|
service.closeSession(result.sessionId);
|
||||||
|
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw if PTY kill throws", () => {
|
||||||
|
mockPtyProcess.kill.mockImplementationOnce(() => {
|
||||||
|
throw new Error("PTY already dead");
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.createSession(mockSocket, {
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
socketId: "socket-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => service.closeSession(result.sessionId)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// closeWorkspaceSessions
|
||||||
|
// ==========================================
|
||||||
|
describe("closeWorkspaceSessions", () => {
|
||||||
|
it("should kill all sessions for a workspace", () => {
|
||||||
|
service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" });
|
||||||
|
service.createSession(createMockSocket("s2"), { workspaceId: "ws-1", socketId: "s2" });
|
||||||
|
|
||||||
|
expect(service.getWorkspaceSessionCount("ws-1")).toBe(2);
|
||||||
|
|
||||||
|
service.closeWorkspaceSessions("ws-1");
|
||||||
|
|
||||||
|
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
||||||
|
expect(mockPtyProcess.kill).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not affect sessions in other workspaces", () => {
|
||||||
|
service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" });
|
||||||
|
service.createSession(createMockSocket("s2"), { workspaceId: "ws-2", socketId: "s2" });
|
||||||
|
|
||||||
|
service.closeWorkspaceSessions("ws-1");
|
||||||
|
|
||||||
|
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
||||||
|
expect(service.getWorkspaceSessionCount("ws-2")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw for workspaces with no sessions", () => {
|
||||||
|
expect(() => service.closeWorkspaceSessions("ws-nonexistent")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// sessionBelongsToWorkspace
|
||||||
|
// ==========================================
|
||||||
|
describe("sessionBelongsToWorkspace", () => {
|
||||||
|
it("should return true for a session belonging to the workspace", () => {
|
||||||
|
const result = service.createSession(mockSocket, {
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
socketId: "socket-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for a session in a different workspace", () => {
|
||||||
|
const result = service.createSession(mockSocket, {
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
socketId: "socket-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-2")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for a nonexistent sessionId", () => {
|
||||||
|
expect(service.sessionBelongsToWorkspace("no-such-id", "ws-1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// getWorkspaceSessionCount
|
||||||
|
// ==========================================
|
||||||
|
describe("getWorkspaceSessionCount", () => {
|
||||||
|
it("should return 0 for workspace with no sessions", () => {
|
||||||
|
expect(service.getWorkspaceSessionCount("empty-ws")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should track session count accurately", () => {
|
||||||
|
service.createSession(mockSocket, { workspaceId: "ws-count", socketId: "s1" });
|
||||||
|
expect(service.getWorkspaceSessionCount("ws-count")).toBe(1);
|
||||||
|
|
||||||
|
service.createSession(createMockSocket("s2"), { workspaceId: "ws-count", socketId: "s2" });
|
||||||
|
expect(service.getWorkspaceSessionCount("ws-count")).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
251
apps/api/src/terminal/terminal.service.ts
Normal file
251
apps/api/src/terminal/terminal.service.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* TerminalService
|
||||||
|
*
|
||||||
|
* Manages PTY (pseudo-terminal) sessions for workspace users.
|
||||||
|
* Spawns real shell processes via node-pty, streams I/O to connected sockets,
|
||||||
|
* and enforces per-workspace session limits.
|
||||||
|
*
|
||||||
|
* Session lifecycle:
|
||||||
|
* - createSession: spawn a new PTY, wire onData/onExit, return sessionId
|
||||||
|
* - writeToSession: send input data to PTY stdin
|
||||||
|
* - resizeSession: resize PTY dimensions (cols x rows)
|
||||||
|
* - closeSession: kill PTY process, emit terminal:exit, cleanup
|
||||||
|
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import * as pty from "node-pty";
|
||||||
|
import type { Socket } from "socket.io";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
/** Maximum concurrent PTY sessions per workspace */
|
||||||
|
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
|
||||||
|
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
|
||||||
|
10
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Default PTY dimensions */
|
||||||
|
const DEFAULT_COLS = 80;
|
||||||
|
const DEFAULT_ROWS = 24;
|
||||||
|
|
||||||
|
export interface TerminalSession {
|
||||||
|
sessionId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
pty: pty.IPty;
|
||||||
|
name?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionOptions {
|
||||||
|
name?: string;
|
||||||
|
cols?: number;
|
||||||
|
rows?: number;
|
||||||
|
cwd?: string;
|
||||||
|
workspaceId: string;
|
||||||
|
socketId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionCreatedResult {
|
||||||
|
sessionId: string;
|
||||||
|
name?: string;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TerminalService {
|
||||||
|
private readonly logger = new Logger(TerminalService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of sessionId -> TerminalSession
|
||||||
|
*/
|
||||||
|
private readonly sessions = new Map<string, TerminalSession>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of workspaceId -> Set<sessionId> for fast per-workspace lookups
|
||||||
|
*/
|
||||||
|
private readonly workspaceSessions = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new PTY session for the given workspace and socket.
|
||||||
|
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
|
||||||
|
*
|
||||||
|
* @throws Error if workspace session limit is exceeded
|
||||||
|
*/
|
||||||
|
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
|
||||||
|
const { workspaceId, name, cwd, socketId } = options;
|
||||||
|
const cols = options.cols ?? DEFAULT_COLS;
|
||||||
|
const rows = options.rows ?? DEFAULT_ROWS;
|
||||||
|
|
||||||
|
// Enforce per-workspace session limit
|
||||||
|
const workspaceSessionIds = this.workspaceSessions.get(workspaceId) ?? new Set<string>();
|
||||||
|
if (workspaceSessionIds.size >= MAX_SESSIONS_PER_WORKSPACE) {
|
||||||
|
throw new Error(
|
||||||
|
`Workspace ${workspaceId} has reached the maximum of ${String(MAX_SESSIONS_PER_WORKSPACE)} concurrent terminal sessions`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const shell = process.env.SHELL ?? "/bin/bash";
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Spawning PTY session ${sessionId} for workspace ${workspaceId} (socket: ${socketId}, shell: ${shell}, ${String(cols)}x${String(rows)})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const ptyProcess = pty.spawn(shell, [], {
|
||||||
|
name: "xterm-256color",
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
cwd: cwd ?? process.cwd(),
|
||||||
|
env: process.env as Record<string, string>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const session: TerminalSession = {
|
||||||
|
sessionId,
|
||||||
|
workspaceId,
|
||||||
|
pty: ptyProcess,
|
||||||
|
...(name !== undefined ? { name } : {}),
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.sessions.set(sessionId, session);
|
||||||
|
|
||||||
|
// Track by workspace
|
||||||
|
if (!this.workspaceSessions.has(workspaceId)) {
|
||||||
|
this.workspaceSessions.set(workspaceId, new Set());
|
||||||
|
}
|
||||||
|
const wsSet = this.workspaceSessions.get(workspaceId);
|
||||||
|
if (wsSet) {
|
||||||
|
wsSet.add(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire PTY stdout/stderr -> terminal:output
|
||||||
|
ptyProcess.onData((data: string) => {
|
||||||
|
socket.emit("terminal:output", { sessionId, data });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire PTY exit -> terminal:exit, cleanup
|
||||||
|
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||||
|
this.logger.log(
|
||||||
|
`PTY session ${sessionId} exited (exitCode: ${String(exitCode)}, signal: ${String(signal ?? "none")})`
|
||||||
|
);
|
||||||
|
socket.emit("terminal:exit", { sessionId, exitCode, signal });
|
||||||
|
this.cleanupSession(sessionId, workspaceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sessionId, ...(name !== undefined ? { name } : {}), cols, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write input data to a PTY session's stdin.
|
||||||
|
*
|
||||||
|
* @throws Error if session not found
|
||||||
|
*/
|
||||||
|
writeToSession(sessionId: string, data: string): void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Terminal session ${sessionId} not found`);
|
||||||
|
}
|
||||||
|
session.pty.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize a PTY session's terminal dimensions.
|
||||||
|
*
|
||||||
|
* @throws Error if session not found
|
||||||
|
*/
|
||||||
|
resizeSession(sessionId: string, cols: number, rows: number): void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Terminal session ${sessionId} not found`);
|
||||||
|
}
|
||||||
|
session.pty.resize(cols, rows);
|
||||||
|
this.logger.debug(`Resized PTY session ${sessionId} to ${String(cols)}x${String(rows)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill and clean up a specific PTY session.
|
||||||
|
* Returns true if the session existed, false if it was already gone.
|
||||||
|
*/
|
||||||
|
closeSession(sessionId: string): boolean {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Closing PTY session ${sessionId} for workspace ${session.workspaceId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
session.pty.kill();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Error killing PTY session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleanupSession(sessionId, session.workspaceId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close all PTY sessions for a workspace (called on client disconnect).
|
||||||
|
*/
|
||||||
|
closeWorkspaceSessions(workspaceId: string): void {
|
||||||
|
const sessionIds = this.workspaceSessions.get(workspaceId);
|
||||||
|
if (!sessionIds || sessionIds.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Closing ${String(sessionIds.size)} PTY session(s) for workspace ${workspaceId} (disconnect)`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Copy to array to avoid mutation during iteration
|
||||||
|
const ids = Array.from(sessionIds);
|
||||||
|
for (const sessionId of ids) {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
try {
|
||||||
|
session.pty.kill();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Error killing PTY session ${sessionId} on disconnect: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.cleanupSession(sessionId, workspaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of active sessions for a workspace.
|
||||||
|
*/
|
||||||
|
getWorkspaceSessionCount(workspaceId: string): number {
|
||||||
|
return this.workspaceSessions.get(workspaceId)?.size ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a session belongs to a given workspace.
|
||||||
|
* Used for access control in the gateway.
|
||||||
|
*/
|
||||||
|
sessionBelongsToWorkspace(sessionId: string, workspaceId: string): boolean {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
return session?.workspaceId === workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal cleanup: remove session from tracking maps.
|
||||||
|
* Does NOT kill the PTY (caller is responsible).
|
||||||
|
*/
|
||||||
|
private cleanupSession(sessionId: string, workspaceId: string): void {
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
|
||||||
|
const workspaceSessionIds = this.workspaceSessions.get(workspaceId);
|
||||||
|
if (workspaceSessionIds) {
|
||||||
|
workspaceSessionIds.delete(sessionId);
|
||||||
|
if (workspaceSessionIds.size === 0) {
|
||||||
|
this.workspaceSessions.delete(workspaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,12 +128,31 @@ function LoginPageContent(): ReactElement {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const callbackURL =
|
const callbackURL =
|
||||||
typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/";
|
typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/";
|
||||||
signIn.oauth2({ providerId, callbackURL }).catch((err: unknown) => {
|
signIn
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
.oauth2({ providerId, callbackURL })
|
||||||
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
.then((result) => {
|
||||||
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
// BetterAuth returns Data | Error union — check for error or missing redirect URL
|
||||||
setOauthLoading(null);
|
const hasError = "error" in result && result.error;
|
||||||
});
|
const hasUrl = "data" in result && result.data?.url;
|
||||||
|
if (hasError || !hasUrl) {
|
||||||
|
const errObj = hasError ? result.error : null;
|
||||||
|
const message =
|
||||||
|
errObj && typeof errObj === "object" && "message" in errObj
|
||||||
|
? String(errObj.message)
|
||||||
|
: "no redirect URL";
|
||||||
|
console.error(`[Auth] OAuth sign-in failed for ${providerId}:`, message);
|
||||||
|
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
||||||
|
setOauthLoading(null);
|
||||||
|
}
|
||||||
|
// If data.url exists, BetterAuth's client will redirect the browser automatically.
|
||||||
|
// No need to reset loading — the page is navigating away.
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
||||||
|
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
||||||
|
setOauthLoading(null);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCredentialsLogin = useCallback(
|
const handleCredentialsLogin = useCallback(
|
||||||
|
|||||||
@@ -765,6 +765,28 @@ body::before {
|
|||||||
animation: scaleIn 0.1s ease-out;
|
animation: scaleIn 0.1s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Streaming cursor for real-time token rendering */
|
||||||
|
@keyframes streaming-cursor-blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
height: 1em;
|
||||||
|
background-color: rgb(var(--accent-primary));
|
||||||
|
border-radius: 1px;
|
||||||
|
animation: streaming-cursor-blink 1s step-end infinite;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
Dashboard Layout — Responsive Grids
|
Dashboard Layout — Responsive Grids
|
||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -64,10 +64,12 @@ function createMockUseChatReturn(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isStreaming: false,
|
||||||
error: null,
|
error: null,
|
||||||
conversationId: null,
|
conversationId: null,
|
||||||
conversationTitle: null,
|
conversationTitle: null,
|
||||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
|
abortStream: vi.fn(),
|
||||||
loadConversation: vi.fn().mockResolvedValue(undefined),
|
loadConversation: vi.fn().mockResolvedValue(undefined),
|
||||||
startNewConversation: vi.fn(),
|
startNewConversation: vi.fn(),
|
||||||
setMessages: vi.fn(),
|
setMessages: vi.fn(),
|
||||||
|
|||||||
@@ -59,14 +59,15 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
|
|
||||||
const { user, isLoading: authLoading } = useAuth();
|
const { user, isLoading: authLoading } = useAuth();
|
||||||
|
|
||||||
// Use the chat hook for state management
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
isLoading: isChatLoading,
|
isLoading: isChatLoading,
|
||||||
|
isStreaming,
|
||||||
error,
|
error,
|
||||||
conversationId,
|
conversationId,
|
||||||
conversationTitle,
|
conversationTitle,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
abortStream,
|
||||||
loadConversation,
|
loadConversation,
|
||||||
startNewConversation,
|
startNewConversation,
|
||||||
clearError,
|
clearError,
|
||||||
@@ -75,15 +76,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connect to WebSocket for real-time updates (when we have a user)
|
const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
|
||||||
const { isConnected: isWsConnected } = useWebSocket(
|
|
||||||
user?.id ?? "", // Use user ID as workspace ID for now
|
|
||||||
"", // Token not needed since we use cookies
|
|
||||||
{
|
|
||||||
// Future: Add handlers for chat-related events
|
|
||||||
// onChatMessage: (msg) => { ... }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -91,7 +84,10 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
const quipTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const quipTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const quipIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const quipIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Expose methods to parent via ref
|
// Identify the streaming message (last assistant message while streaming)
|
||||||
|
const streamingMessageId =
|
||||||
|
isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined;
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
loadConversation: async (cId: string): Promise<void> => {
|
loadConversation: async (cId: string): Promise<void> => {
|
||||||
await loadConversation(cId);
|
await loadConversation(cId);
|
||||||
@@ -110,7 +106,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages, scrollToBottom]);
|
}, [messages, scrollToBottom]);
|
||||||
|
|
||||||
// Notify parent of conversation changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (conversationId && conversationTitle) {
|
if (conversationId && conversationTitle) {
|
||||||
onConversationChange?.(conversationId, {
|
onConversationChange?.(conversationId, {
|
||||||
@@ -125,7 +120,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
}
|
}
|
||||||
}, [conversationId, conversationTitle, initialProjectId, onConversationChange]);
|
}, [conversationId, conversationTitle, initialProjectId, onConversationChange]);
|
||||||
|
|
||||||
// Global keyboard shortcut: Ctrl+/ to focus input
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
||||||
@@ -139,20 +133,17 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Show loading quips
|
// Show loading quips only during non-streaming load (initial fetch wait)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isChatLoading) {
|
if (isChatLoading && !isStreaming) {
|
||||||
// Show first quip after 3 seconds
|
|
||||||
quipTimerRef.current = setTimeout(() => {
|
quipTimerRef.current = setTimeout(() => {
|
||||||
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
// Change quip every 5 seconds
|
|
||||||
quipIntervalRef.current = setInterval(() => {
|
quipIntervalRef.current = setInterval(() => {
|
||||||
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
} else {
|
} else {
|
||||||
// Clear timers when loading stops
|
|
||||||
if (quipTimerRef.current) {
|
if (quipTimerRef.current) {
|
||||||
clearTimeout(quipTimerRef.current);
|
clearTimeout(quipTimerRef.current);
|
||||||
quipTimerRef.current = null;
|
quipTimerRef.current = null;
|
||||||
@@ -168,7 +159,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
if (quipTimerRef.current) clearTimeout(quipTimerRef.current);
|
if (quipTimerRef.current) clearTimeout(quipTimerRef.current);
|
||||||
if (quipIntervalRef.current) clearInterval(quipIntervalRef.current);
|
if (quipIntervalRef.current) clearInterval(quipIntervalRef.current);
|
||||||
};
|
};
|
||||||
}, [isChatLoading]);
|
}, [isChatLoading, isStreaming]);
|
||||||
|
|
||||||
const handleSendMessage = useCallback(
|
const handleSendMessage = useCallback(
|
||||||
async (content: string) => {
|
async (content: string) => {
|
||||||
@@ -177,7 +168,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
[sendMessage]
|
[sendMessage]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show loading state while auth is loading
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -227,6 +217,8 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
<MessageList
|
<MessageList
|
||||||
messages={messages as (Message & { thinking?: string })[]}
|
messages={messages as (Message & { thinking?: string })[]}
|
||||||
isLoading={isChatLoading}
|
isLoading={isChatLoading}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
{...(streamingMessageId != null ? { streamingMessageId } : {})}
|
||||||
loadingQuip={loadingQuip}
|
loadingQuip={loadingQuip}
|
||||||
/>
|
/>
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
@@ -294,6 +286,8 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
disabled={isChatLoading || !user}
|
disabled={isChatLoading || !user}
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onStopStreaming={abortStream}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,13 +7,20 @@ interface ChatInputProps {
|
|||||||
onSend: (message: string) => void;
|
onSend: (message: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
onStopStreaming?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React.JSX.Element {
|
export function ChatInput({
|
||||||
|
onSend,
|
||||||
|
disabled,
|
||||||
|
inputRef,
|
||||||
|
isStreaming = false,
|
||||||
|
onStopStreaming,
|
||||||
|
}: ChatInputProps): React.JSX.Element {
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [version, setVersion] = useState<string | null>(null);
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch version from static version.json (generated at build time)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
interface VersionData {
|
interface VersionData {
|
||||||
version?: string;
|
version?: string;
|
||||||
@@ -24,7 +31,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
|||||||
.then((res) => res.json() as Promise<VersionData>)
|
.then((res) => res.json() as Promise<VersionData>)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.version) {
|
if (data.version) {
|
||||||
// Format as "version+commit" for full build identification
|
|
||||||
const fullVersion = data.commit ? `${data.version}+${data.commit}` : data.version;
|
const fullVersion = data.commit ? `${data.version}+${data.commit}` : data.version;
|
||||||
setVersion(fullVersion);
|
setVersion(fullVersion);
|
||||||
}
|
}
|
||||||
@@ -35,20 +41,22 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (message.trim() && !disabled) {
|
if (message.trim() && !disabled && !isStreaming) {
|
||||||
onSend(message);
|
onSend(message);
|
||||||
setMessage("");
|
setMessage("");
|
||||||
}
|
}
|
||||||
}, [message, onSend, disabled]);
|
}, [message, onSend, disabled, isStreaming]);
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
onStopStreaming?.();
|
||||||
|
}, [onStopStreaming]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
// Enter to send (without Shift)
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
// Ctrl/Cmd + Enter to send (alternative)
|
|
||||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
@@ -61,6 +69,7 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
|||||||
const maxCharacters = 4000;
|
const maxCharacters = 4000;
|
||||||
const isNearLimit = characterCount > maxCharacters * 0.9;
|
const isNearLimit = characterCount > maxCharacters * 0.9;
|
||||||
const isOverLimit = characterCount > maxCharacters;
|
const isOverLimit = characterCount > maxCharacters;
|
||||||
|
const isInputDisabled = disabled ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -69,7 +78,10 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
|||||||
className="relative rounded-lg border transition-all duration-150"
|
className="relative rounded-lg border transition-all duration-150"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "rgb(var(--surface-0))",
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
borderColor: disabled ? "rgb(var(--border-default))" : "rgb(var(--border-strong))",
|
borderColor:
|
||||||
|
isInputDisabled || isStreaming
|
||||||
|
? "rgb(var(--border-default))"
|
||||||
|
: "rgb(var(--border-strong))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -79,8 +91,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
|||||||
setMessage(e.target.value);
|
setMessage(e.target.value);
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type a message..."
|
placeholder={isStreaming ? "AI is responding..." : "Type a message..."}
|
||||||
disabled={disabled}
|
disabled={isInputDisabled || isStreaming}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50"
|
className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50"
|
||||||
style={{
|
style={{
|
||||||
@@ -97,28 +109,47 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
|||||||
aria-describedby="input-help"
|
aria-describedby="input-help"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Send Button */}
|
{/* Send / Stop Button */}
|
||||||
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
||||||
<button
|
{isStreaming ? (
|
||||||
onClick={handleSubmit}
|
<button
|
||||||
disabled={(disabled ?? !message.trim()) || isOverLimit}
|
onClick={handleStop}
|
||||||
className="btn-primary btn-sm rounded-md"
|
className="btn-sm rounded-md flex items-center gap-1.5"
|
||||||
style={{
|
style={{
|
||||||
opacity: disabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
backgroundColor: "rgb(var(--semantic-error))",
|
||||||
}}
|
color: "white",
|
||||||
aria-label="Send message"
|
padding: "0.25rem 0.75rem",
|
||||||
>
|
}}
|
||||||
<svg
|
aria-label="Stop generating"
|
||||||
className="h-4 w-4"
|
title="Stop generating"
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
>
|
||||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
</svg>
|
<rect x="6" y="6" width="12" height="12" rx="1" />
|
||||||
<span className="hidden sm:inline">Send</span>
|
</svg>
|
||||||
</button>
|
<span className="hidden sm:inline text-sm font-medium">Stop</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isInputDisabled || !message.trim() || isOverLimit}
|
||||||
|
className="btn-primary btn-sm rounded-md"
|
||||||
|
style={{
|
||||||
|
opacity: isInputDisabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">Send</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -128,7 +159,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
|||||||
style={{ color: "rgb(var(--text-muted))" }}
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
id="input-help"
|
id="input-help"
|
||||||
>
|
>
|
||||||
{/* Keyboard Shortcuts */}
|
|
||||||
<div className="hidden items-center gap-4 sm:flex">
|
<div className="hidden items-center gap-4 sm:flex">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="kbd">Enter</span>
|
<span className="kbd">Enter</span>
|
||||||
@@ -142,10 +172,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile hint */}
|
|
||||||
<div className="sm:hidden">Tap send or press Enter</div>
|
<div className="sm:hidden">Tap send or press Enter</div>
|
||||||
|
|
||||||
{/* Character Count */}
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { Message } from "@/hooks/useChat";
|
import type { Message } from "@/hooks/useChat";
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
streamingMessageId?: string;
|
||||||
loadingQuip?: string | null;
|
loadingQuip?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +16,6 @@ interface MessageListProps {
|
|||||||
* Extracts <thinking>...</thinking> or <think>...</think> blocks.
|
* Extracts <thinking>...</thinking> or <think>...</think> blocks.
|
||||||
*/
|
*/
|
||||||
function parseThinking(content: string): { thinking: string | null; response: string } {
|
function parseThinking(content: string): { thinking: string | null; response: string } {
|
||||||
// Match <thinking>...</thinking> or <think>...</think> blocks
|
|
||||||
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
|
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
|
||||||
const matches = content.match(thinkingRegex);
|
const matches = content.match(thinkingRegex);
|
||||||
|
|
||||||
@@ -22,14 +23,12 @@ function parseThinking(content: string): { thinking: string | null; response: st
|
|||||||
return { thinking: null, response: content };
|
return { thinking: null, response: content };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract thinking content
|
|
||||||
let thinking = "";
|
let thinking = "";
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
|
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
|
||||||
thinking += innerContent.trim() + "\n";
|
thinking += innerContent.trim() + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove thinking blocks from response
|
|
||||||
const response = content.replace(thinkingRegex, "").trim();
|
const response = content.replace(thinkingRegex, "").trim();
|
||||||
|
|
||||||
const trimmedThinking = thinking.trim();
|
const trimmedThinking = thinking.trim();
|
||||||
@@ -42,25 +41,47 @@ function parseThinking(content: string): { thinking: string | null; response: st
|
|||||||
export function MessageList({
|
export function MessageList({
|
||||||
messages,
|
messages,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isStreaming = false,
|
||||||
|
streamingMessageId,
|
||||||
loadingQuip,
|
loadingQuip,
|
||||||
}: MessageListProps): React.JSX.Element {
|
}: MessageListProps): React.JSX.Element {
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when messages change or streaming tokens arrive
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming || isLoading) {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [messages, isStreaming, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" role="log" aria-label="Chat messages">
|
<div className="space-y-6" role="log" aria-label="Chat messages">
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<MessageBubble key={message.id} message={message} />
|
<MessageBubble
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
isStreaming={isStreaming && message.id === streamingMessageId}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isLoading && <LoadingIndicator {...(loadingQuip != null && { quip: loadingQuip })} />}
|
{isLoading && !isStreaming && (
|
||||||
|
<LoadingIndicator {...(loadingQuip != null && { quip: loadingQuip })} />
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
interface MessageBubbleProps {
|
||||||
|
message: Message;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ message, isStreaming = false }: MessageBubbleProps): React.JSX.Element {
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||||
|
|
||||||
// Parse thinking from content (or use pre-parsed thinking field)
|
|
||||||
const { thinking, response } = message.thinking
|
const { thinking, response } = message.thinking
|
||||||
? { thinking: message.thinking, response: message.content }
|
? { thinking: message.thinking, response: message.content }
|
||||||
: parseThinking(message.content);
|
: parseThinking(message.content);
|
||||||
@@ -73,7 +94,6 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
|||||||
setCopied(false);
|
setCopied(false);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Silently fail - clipboard copy is non-critical
|
|
||||||
void err;
|
void err;
|
||||||
}
|
}
|
||||||
}, [response]);
|
}, [response]);
|
||||||
@@ -106,8 +126,21 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
|||||||
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
|
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||||
{isUser ? "You" : "AI Assistant"}
|
{isUser ? "You" : "AI Assistant"}
|
||||||
</span>
|
</span>
|
||||||
|
{/* Streaming indicator in header */}
|
||||||
|
{!isUser && isStreaming && (
|
||||||
|
<span
|
||||||
|
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--accent-primary) / 0.15)",
|
||||||
|
color: "rgb(var(--accent-primary))",
|
||||||
|
}}
|
||||||
|
aria-label="Streaming"
|
||||||
|
>
|
||||||
|
streaming
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{/* Model indicator for assistant messages */}
|
{/* Model indicator for assistant messages */}
|
||||||
{!isUser && message.model && (
|
{!isUser && message.model && !isStreaming && (
|
||||||
<span
|
<span
|
||||||
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||||
style={{
|
style={{
|
||||||
@@ -200,43 +233,54 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
|||||||
border: isUser ? "none" : "1px solid rgb(var(--border-default))",
|
border: isUser ? "none" : "1px solid rgb(var(--border-default))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{response}</p>
|
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||||
|
{response}
|
||||||
{/* Copy Button - appears on hover */}
|
{/* Blinking cursor during streaming */}
|
||||||
<button
|
{isStreaming && !isUser && (
|
||||||
onClick={handleCopy}
|
<span
|
||||||
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
|
className="streaming-cursor inline-block ml-0.5 align-middle"
|
||||||
style={{
|
aria-hidden="true"
|
||||||
backgroundColor: "rgb(var(--surface-0))",
|
/>
|
||||||
borderColor: "rgb(var(--border-default))",
|
|
||||||
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
|
|
||||||
}}
|
|
||||||
aria-label={copied ? "Copied!" : "Copy message"}
|
|
||||||
title={copied ? "Copied!" : "Copy to clipboard"}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<svg
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<polyline points="20 6 9 17 4 12" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</p>
|
||||||
|
|
||||||
|
{/* Copy Button - hidden while streaming */}
|
||||||
|
{!isStreaming && (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
|
||||||
|
}}
|
||||||
|
aria-label={copied ? "Copied!" : "Copy message"}
|
||||||
|
title={copied ? "Copied!" : "Copy to clipboard"}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { KnowledgeEditor } from "../KnowledgeEditor";
|
||||||
|
|
||||||
|
// Mock Tiptap since it requires a full DOM with contenteditable support
|
||||||
|
vi.mock("@tiptap/react", () => {
|
||||||
|
const EditorContent = ({ editor }: { editor: unknown }): React.JSX.Element => (
|
||||||
|
<div data-testid="editor-content" data-editor={editor ? "ready" : "null"} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
useEditor: (): null => null,
|
||||||
|
EditorContent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock tiptap-markdown
|
||||||
|
vi.mock("tiptap-markdown", () => ({
|
||||||
|
Markdown: {
|
||||||
|
configure: vi.fn().mockReturnValue({}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock lowlight
|
||||||
|
vi.mock("lowlight", () => ({
|
||||||
|
common: {},
|
||||||
|
createLowlight: vi.fn().mockReturnValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock extensions
|
||||||
|
vi.mock("@tiptap/starter-kit", () => ({
|
||||||
|
default: { configure: vi.fn().mockReturnValue({}) },
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-link", () => ({
|
||||||
|
default: { configure: vi.fn().mockReturnValue({}) },
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-table", () => ({
|
||||||
|
Table: { configure: vi.fn().mockReturnValue({}) },
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-table-row", () => ({
|
||||||
|
TableRow: {},
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-table-cell", () => ({
|
||||||
|
TableCell: {},
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-table-header", () => ({
|
||||||
|
TableHeader: {},
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-code-block-lowlight", () => ({
|
||||||
|
default: { configure: vi.fn().mockReturnValue({}) },
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-placeholder", () => ({
|
||||||
|
default: { configure: vi.fn().mockReturnValue({}) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("KnowledgeEditor", (): void => {
|
||||||
|
const defaultProps = {
|
||||||
|
content: "",
|
||||||
|
onChange: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render loading state when editor is null", (): void => {
|
||||||
|
render(<KnowledgeEditor {...defaultProps} />);
|
||||||
|
expect(screen.getByText("Loading editor...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render with knowledge-editor class", (): void => {
|
||||||
|
// When editor is null, the loading fallback renders instead
|
||||||
|
const { container } = render(<KnowledgeEditor {...defaultProps} />);
|
||||||
|
expect(container.firstChild).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept optional placeholder prop", (): void => {
|
||||||
|
// Smoke test that it doesn't crash with custom placeholder
|
||||||
|
render(<KnowledgeEditor {...defaultProps} placeholder="Custom placeholder" />);
|
||||||
|
expect(screen.getByText("Loading editor...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept optional editable prop", (): void => {
|
||||||
|
// Smoke test that it doesn't crash when read-only
|
||||||
|
render(<KnowledgeEditor {...defaultProps} editable={false} />);
|
||||||
|
expect(screen.getByText("Loading editor...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { WidgetConfigDialog } from "../WidgetConfigDialog";
|
||||||
|
|
||||||
|
describe("WidgetConfigDialog", (): void => {
|
||||||
|
const defaultProps = {
|
||||||
|
widgetId: "TasksWidget-abc123",
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render nothing when closed", (): void => {
|
||||||
|
const { container } = render(<WidgetConfigDialog {...defaultProps} open={false} />);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render dialog when open", (): void => {
|
||||||
|
render(<WidgetConfigDialog {...defaultProps} />);
|
||||||
|
expect(screen.getByRole("dialog", { name: "Widget Settings" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show widget name in header", (): void => {
|
||||||
|
render(<WidgetConfigDialog {...defaultProps} />);
|
||||||
|
// TasksWidget is registered with displayName "Tasks"
|
||||||
|
expect(screen.getByText(/Tasks.*Settings/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show placeholder message when no config schema", (): void => {
|
||||||
|
render(<WidgetConfigDialog {...defaultProps} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText("No configuration options available for this widget yet.")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onClose when close button is clicked", async (): Promise<void> => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<WidgetConfigDialog {...defaultProps} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText("Close"));
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onClose when footer Close button is clicked", async (): Promise<void> => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<WidgetConfigDialog {...defaultProps} />);
|
||||||
|
|
||||||
|
// Footer has a "Close" text button
|
||||||
|
await user.click(screen.getByText("Close"));
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle unknown widget type gracefully", (): void => {
|
||||||
|
render(<WidgetConfigDialog {...defaultProps} widgetId="UnknownWidget-xyz" />);
|
||||||
|
// Should show fallback "Widget Settings" when type is not in registry
|
||||||
|
expect(screen.getByText("Widget Settings")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
101
apps/web/src/components/widgets/__tests__/WidgetPicker.test.tsx
Normal file
101
apps/web/src/components/widgets/__tests__/WidgetPicker.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { WidgetPicker } from "../WidgetPicker";
|
||||||
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
|
|
||||||
|
describe("WidgetPicker", (): void => {
|
||||||
|
const defaultProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onAddWidget: vi.fn(),
|
||||||
|
currentLayout: [] as WidgetPlacement[],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render nothing when closed", (): void => {
|
||||||
|
const { container } = render(<WidgetPicker {...defaultProps} open={false} />);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render dialog when open", (): void => {
|
||||||
|
render(<WidgetPicker {...defaultProps} />);
|
||||||
|
expect(screen.getByRole("dialog", { name: "Add Widget" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display Add Widget heading", (): void => {
|
||||||
|
render(<WidgetPicker {...defaultProps} />);
|
||||||
|
expect(screen.getByText("Add Widget")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render search input", (): void => {
|
||||||
|
render(<WidgetPicker {...defaultProps} />);
|
||||||
|
expect(screen.getByPlaceholderText("Search widgets...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display available widgets", (): void => {
|
||||||
|
render(<WidgetPicker {...defaultProps} />);
|
||||||
|
// Widget registry has multiple widgets; at least one Add button should appear
|
||||||
|
const addButtons = screen.getAllByText("Add");
|
||||||
|
expect(addButtons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter widgets by search text", async (): Promise<void> => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<WidgetPicker {...defaultProps} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText("Search widgets...");
|
||||||
|
// Type a search term that won't match any widget
|
||||||
|
await user.type(searchInput, "zzz-nonexistent-widget-zzz");
|
||||||
|
|
||||||
|
expect(screen.getByText("No widgets found")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onAddWidget when Add is clicked", async (): Promise<void> => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onAdd = vi.fn();
|
||||||
|
render(<WidgetPicker {...defaultProps} onAddWidget={onAdd} />);
|
||||||
|
|
||||||
|
const addButtons = screen.getAllByText("Add");
|
||||||
|
await user.click(addButtons[0]!);
|
||||||
|
|
||||||
|
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||||
|
const placement = onAdd.mock.calls[0]![0] as WidgetPlacement;
|
||||||
|
expect(placement).toHaveProperty("i");
|
||||||
|
expect(placement).toHaveProperty("x");
|
||||||
|
expect(placement).toHaveProperty("y");
|
||||||
|
expect(placement).toHaveProperty("w");
|
||||||
|
expect(placement).toHaveProperty("h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onClose when close button is clicked", async (): Promise<void> => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<WidgetPicker {...defaultProps} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText("Close"));
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should place new widgets after existing layout items", async (): Promise<void> => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onAdd = vi.fn();
|
||||||
|
const existingLayout: WidgetPlacement[] = [
|
||||||
|
{ i: "test-1", x: 0, y: 0, w: 6, h: 3 },
|
||||||
|
{ i: "test-2", x: 0, y: 3, w: 6, h: 2 },
|
||||||
|
];
|
||||||
|
render(<WidgetPicker {...defaultProps} onAddWidget={onAdd} currentLayout={existingLayout} />);
|
||||||
|
|
||||||
|
const addButtons = screen.getAllByText("Add");
|
||||||
|
await user.click(addButtons[0]!);
|
||||||
|
|
||||||
|
const placement = onAdd.mock.calls[0]![0] as WidgetPlacement;
|
||||||
|
// Should be placed at y=5 (3+2) to avoid overlap
|
||||||
|
expect(placement.y).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import type { ChatResponse } from "@/lib/api/chat";
|
|||||||
// Mock the API modules - use importOriginal to preserve types/enums
|
// Mock the API modules - use importOriginal to preserve types/enums
|
||||||
vi.mock("@/lib/api/chat", () => ({
|
vi.mock("@/lib/api/chat", () => ({
|
||||||
sendChatMessage: vi.fn(),
|
sendChatMessage: vi.fn(),
|
||||||
|
streamChatMessage: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/api/ideas", async (importOriginal) => {
|
vi.mock("@/lib/api/ideas", async (importOriginal) => {
|
||||||
@@ -30,6 +31,9 @@ vi.mock("@/lib/api/ideas", async (importOriginal) => {
|
|||||||
const mockSendChatMessage = chatApi.sendChatMessage as MockedFunction<
|
const mockSendChatMessage = chatApi.sendChatMessage as MockedFunction<
|
||||||
typeof chatApi.sendChatMessage
|
typeof chatApi.sendChatMessage
|
||||||
>;
|
>;
|
||||||
|
const mockStreamChatMessage = chatApi.streamChatMessage as MockedFunction<
|
||||||
|
typeof chatApi.streamChatMessage
|
||||||
|
>;
|
||||||
const mockCreateConversation = ideasApi.createConversation as MockedFunction<
|
const mockCreateConversation = ideasApi.createConversation as MockedFunction<
|
||||||
typeof ideasApi.createConversation
|
typeof ideasApi.createConversation
|
||||||
>;
|
>;
|
||||||
@@ -70,9 +74,62 @@ function createMockIdea(id: string, title: string, content: string): Idea {
|
|||||||
} as Idea;
|
} as Idea;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure streamChatMessage to immediately fail,
|
||||||
|
* triggering the fallback to sendChatMessage.
|
||||||
|
*/
|
||||||
|
function makeStreamFail(): void {
|
||||||
|
mockStreamChatMessage.mockImplementation(
|
||||||
|
(
|
||||||
|
_request,
|
||||||
|
_onChunk,
|
||||||
|
_onComplete,
|
||||||
|
onError: (err: Error) => void,
|
||||||
|
_signal?: AbortSignal
|
||||||
|
): void => {
|
||||||
|
// Call synchronously so the Promise rejects immediately
|
||||||
|
onError(new Error("Streaming not available"));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure streamChatMessage to succeed with given tokens.
|
||||||
|
* Uses a ref-style object to share cancellation state across the async boundary.
|
||||||
|
*/
|
||||||
|
function makeStreamSucceed(tokens: string[]): void {
|
||||||
|
mockStreamChatMessage.mockImplementation(
|
||||||
|
(
|
||||||
|
_request,
|
||||||
|
onChunk: (chunk: string) => void,
|
||||||
|
onComplete: () => void,
|
||||||
|
_onError: (err: Error) => void,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): void => {
|
||||||
|
const state = { cancelled: false };
|
||||||
|
signal?.addEventListener("abort", () => {
|
||||||
|
state.cancelled = true;
|
||||||
|
});
|
||||||
|
const run = async (): Promise<void> => {
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (state.cancelled) return;
|
||||||
|
await Promise.resolve();
|
||||||
|
onChunk(token);
|
||||||
|
}
|
||||||
|
if (!state.cancelled) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void run();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("useChat", () => {
|
describe("useChat", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Default: streaming fails so tests exercise the fallback path
|
||||||
|
makeStreamFail();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -87,13 +144,19 @@ describe("useChat", () => {
|
|||||||
expect(result.current.messages[0]?.role).toBe("assistant");
|
expect(result.current.messages[0]?.role).toBe("assistant");
|
||||||
expect(result.current.messages[0]?.id).toBe("welcome");
|
expect(result.current.messages[0]?.id).toBe("welcome");
|
||||||
expect(result.current.isLoading).toBe(false);
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
expect(result.current.error).toBeNull();
|
expect(result.current.error).toBeNull();
|
||||||
expect(result.current.conversationId).toBeNull();
|
expect(result.current.conversationId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should expose abortStream function", () => {
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
expect(typeof result.current.abortStream).toBe("function");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendMessage", () => {
|
describe("sendMessage (fallback path when streaming fails)", () => {
|
||||||
it("should add user message and assistant response", async () => {
|
it("should add user message and assistant response via fallback", async () => {
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Hello there!"));
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Hello there!"));
|
||||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||||
|
|
||||||
@@ -119,47 +182,13 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(mockSendChatMessage).not.toHaveBeenCalled();
|
expect(mockSendChatMessage).not.toHaveBeenCalled();
|
||||||
|
expect(mockStreamChatMessage).not.toHaveBeenCalled();
|
||||||
expect(result.current.messages).toHaveLength(1); // only welcome
|
expect(result.current.messages).toHaveLength(1); // only welcome
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not send while loading", async () => {
|
|
||||||
let resolveFirst: ((value: ChatResponse) => void) | undefined;
|
|
||||||
const firstPromise = new Promise<ChatResponse>((resolve) => {
|
|
||||||
resolveFirst = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
mockSendChatMessage.mockReturnValueOnce(firstPromise);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
// Start first message
|
|
||||||
act(() => {
|
|
||||||
void result.current.sendMessage("First");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.isLoading).toBe(true);
|
|
||||||
|
|
||||||
// Try to send second while loading
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("Second");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should only have one call
|
|
||||||
expect(mockSendChatMessage).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Cleanup - resolve the pending promise
|
|
||||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
|
||||||
await act(async () => {
|
|
||||||
if (resolveFirst) {
|
|
||||||
resolveFirst(createMockChatResponse("Response"));
|
|
||||||
}
|
|
||||||
// Allow promise to settle
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle API errors gracefully", async () => {
|
it("should handle API errors gracefully", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("API Error"));
|
mockSendChatMessage.mockRejectedValueOnce(new Error("API Error"));
|
||||||
|
|
||||||
const onError = vi.fn();
|
const onError = vi.fn();
|
||||||
@@ -171,46 +200,178 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
||||||
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
||||||
// Should have welcome + user + error message
|
|
||||||
expect(result.current.messages).toHaveLength(3);
|
expect(result.current.messages).toHaveLength(3);
|
||||||
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("streaming path", () => {
|
||||||
|
it("should stream tokens into assistant message", async () => {
|
||||||
|
const tokens = ["Hello", " world", "!"];
|
||||||
|
makeStreamSucceed(tokens);
|
||||||
|
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages).toHaveLength(3);
|
||||||
|
expect(result.current.messages[2]?.role).toBe("assistant");
|
||||||
|
expect(result.current.messages[2]?.content).toBe("Hello world!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set isStreaming true during streaming then false when done", async () => {
|
||||||
|
let capturedOnChunk: ((chunk: string) => void) | undefined;
|
||||||
|
let capturedOnComplete: (() => void) | undefined;
|
||||||
|
|
||||||
|
mockStreamChatMessage.mockImplementation(
|
||||||
|
(
|
||||||
|
_request,
|
||||||
|
onChunk: (chunk: string) => void,
|
||||||
|
onComplete: () => void,
|
||||||
|
_onError: (err: Error) => void
|
||||||
|
): void => {
|
||||||
|
capturedOnChunk = onChunk;
|
||||||
|
capturedOnComplete = onComplete;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
let sendDone = false;
|
||||||
|
act(() => {
|
||||||
|
void result.current.sendMessage("Hello").then(() => {
|
||||||
|
sendDone = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send first token (triggers streaming state)
|
||||||
|
await act(async () => {
|
||||||
|
capturedOnChunk?.("Hello");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
|
||||||
|
// Complete the stream
|
||||||
|
await act(async () => {
|
||||||
|
capturedOnComplete?.();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
expect(sendDone).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep partial content on abort", async () => {
|
||||||
|
let capturedOnChunk: ((chunk: string) => void) | undefined;
|
||||||
|
|
||||||
|
mockStreamChatMessage.mockImplementation(
|
||||||
|
(
|
||||||
|
_request,
|
||||||
|
onChunk: (chunk: string) => void,
|
||||||
|
_onComplete: () => void,
|
||||||
|
_onError: (err: Error) => void,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): void => {
|
||||||
|
capturedOnChunk = onChunk;
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener("abort", () => {
|
||||||
|
// Stream aborted
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current.sendMessage("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
capturedOnChunk?.("Partial");
|
||||||
|
capturedOnChunk?.(" content");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.abortStream();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
const assistantMsg = result.current.messages.find(
|
||||||
|
(m) => m.role === "assistant" && m.id !== "welcome"
|
||||||
|
);
|
||||||
|
expect(assistantMsg?.content).toBe("Partial content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not send while streaming", async () => {
|
||||||
|
let capturedOnChunk: ((chunk: string) => void) | undefined;
|
||||||
|
|
||||||
|
mockStreamChatMessage.mockImplementation(
|
||||||
|
(
|
||||||
|
_request,
|
||||||
|
onChunk: (chunk: string) => void,
|
||||||
|
_onComplete: () => void,
|
||||||
|
_onError: (err: Error) => void
|
||||||
|
): void => {
|
||||||
|
capturedOnChunk = onChunk;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current.sendMessage("First");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
capturedOnChunk?.("token");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Second");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only one stream call
|
||||||
|
expect(mockStreamChatMessage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("rapid sends - stale closure prevention", () => {
|
describe("rapid sends - stale closure prevention", () => {
|
||||||
it("should not lose messages on rapid sequential sends", async () => {
|
it("should not lose messages on rapid sequential sends", async () => {
|
||||||
// This test verifies that functional state updates prevent message loss
|
// Use streaming success path for deterministic behavior
|
||||||
// when multiple messages are sent in quick succession
|
makeStreamSucceed(["Response 1"]);
|
||||||
|
|
||||||
let callCount = 0;
|
|
||||||
mockSendChatMessage.mockImplementation(async (): Promise<ChatResponse> => {
|
|
||||||
callCount++;
|
|
||||||
// Small delay to simulate network
|
|
||||||
await Promise.resolve();
|
|
||||||
return createMockChatResponse(`Response ${String(callCount)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", ""));
|
mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", ""));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
// Send first message
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Message 1");
|
await result.current.sendMessage("Message 1");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify first message cycle complete
|
|
||||||
expect(result.current.messages).toHaveLength(3); // welcome + user1 + assistant1
|
expect(result.current.messages).toHaveLength(3); // welcome + user1 + assistant1
|
||||||
|
|
||||||
// Send second message
|
makeStreamSucceed(["Response 2"]);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Message 2");
|
await result.current.sendMessage("Message 2");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify all messages are present (no data loss)
|
|
||||||
expect(result.current.messages).toHaveLength(5); // welcome + user1 + assistant1 + user2 + assistant2
|
expect(result.current.messages).toHaveLength(5); // welcome + user1 + assistant1 + user2 + assistant2
|
||||||
|
|
||||||
// Verify message order and content
|
|
||||||
const userMessages = result.current.messages.filter((m) => m.role === "user");
|
const userMessages = result.current.messages.filter((m) => m.role === "user");
|
||||||
expect(userMessages).toHaveLength(2);
|
expect(userMessages).toHaveLength(2);
|
||||||
expect(userMessages[0]?.content).toBe("Message 1");
|
expect(userMessages[0]?.content).toBe("Message 1");
|
||||||
@@ -218,69 +379,56 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should use functional updates for all message state changes", async () => {
|
it("should use functional updates for all message state changes", async () => {
|
||||||
// This test verifies that the implementation uses functional updates
|
|
||||||
// by checking that messages accumulate correctly
|
|
||||||
|
|
||||||
mockSendChatMessage.mockResolvedValue(createMockChatResponse("Response"));
|
|
||||||
mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", ""));
|
mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", ""));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
// Track message count after each operation
|
|
||||||
const messageCounts: number[] = [];
|
const messageCounts: number[] = [];
|
||||||
|
|
||||||
|
makeStreamSucceed(["R1"]);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Test 1");
|
await result.current.sendMessage("Test 1");
|
||||||
});
|
});
|
||||||
messageCounts.push(result.current.messages.length);
|
messageCounts.push(result.current.messages.length);
|
||||||
|
|
||||||
|
makeStreamSucceed(["R2"]);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Test 2");
|
await result.current.sendMessage("Test 2");
|
||||||
});
|
});
|
||||||
messageCounts.push(result.current.messages.length);
|
messageCounts.push(result.current.messages.length);
|
||||||
|
|
||||||
|
makeStreamSucceed(["R3"]);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Test 3");
|
await result.current.sendMessage("Test 3");
|
||||||
});
|
});
|
||||||
messageCounts.push(result.current.messages.length);
|
messageCounts.push(result.current.messages.length);
|
||||||
|
|
||||||
// Should accumulate: 3, 5, 7 (welcome + pairs of user/assistant)
|
|
||||||
expect(messageCounts).toEqual([3, 5, 7]);
|
expect(messageCounts).toEqual([3, 5, 7]);
|
||||||
|
|
||||||
// Verify final state has all messages
|
|
||||||
expect(result.current.messages).toHaveLength(7);
|
expect(result.current.messages).toHaveLength(7);
|
||||||
const userMessages = result.current.messages.filter((m) => m.role === "user");
|
const userMessages = result.current.messages.filter((m) => m.role === "user");
|
||||||
expect(userMessages).toHaveLength(3);
|
expect(userMessages).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should maintain correct message order with ref-based state tracking", async () => {
|
it("should maintain correct message order with ref-based state tracking", async () => {
|
||||||
// This test verifies that messagesRef is properly synchronized
|
|
||||||
|
|
||||||
const responses = ["First response", "Second response", "Third response"];
|
|
||||||
let responseIndex = 0;
|
|
||||||
|
|
||||||
mockSendChatMessage.mockImplementation((): Promise<ChatResponse> => {
|
|
||||||
const response = responses[responseIndex++];
|
|
||||||
return Promise.resolve(createMockChatResponse(response ?? ""));
|
|
||||||
});
|
|
||||||
|
|
||||||
mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", ""));
|
mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", ""));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
makeStreamSucceed(["First response"]);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Query 1");
|
await result.current.sendMessage("Query 1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
makeStreamSucceed(["Second response"]);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Query 2");
|
await result.current.sendMessage("Query 2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
makeStreamSucceed(["Third response"]);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Query 3");
|
await result.current.sendMessage("Query 3");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify messages are in correct order
|
|
||||||
const messages = result.current.messages;
|
const messages = result.current.messages;
|
||||||
expect(messages[0]?.id).toBe("welcome");
|
expect(messages[0]?.id).toBe("welcome");
|
||||||
expect(messages[1]?.content).toBe("Query 1");
|
expect(messages[1]?.content).toBe("Query 1");
|
||||||
@@ -337,14 +485,12 @@ describe("useChat", () => {
|
|||||||
await result.current.loadConversation("conv-bad");
|
await result.current.loadConversation("conv-bad");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should fall back to welcome message
|
|
||||||
expect(result.current.messages).toHaveLength(1);
|
expect(result.current.messages).toHaveLength(1);
|
||||||
expect(result.current.messages[0]?.id).toBe("welcome");
|
expect(result.current.messages[0]?.id).toBe("welcome");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fall back to welcome message when stored data has wrong shape", async () => {
|
it("should fall back to welcome message when stored data has wrong shape", async () => {
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
// Valid JSON but wrong shape (object instead of array, missing required fields)
|
|
||||||
mockGetIdea.mockResolvedValueOnce(
|
mockGetIdea.mockResolvedValueOnce(
|
||||||
createMockIdea("conv-bad", "Wrong Shape", JSON.stringify({ not: "an array" }))
|
createMockIdea("conv-bad", "Wrong Shape", JSON.stringify({ not: "an array" }))
|
||||||
);
|
);
|
||||||
@@ -408,7 +554,6 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
// Should resolve without throwing - errors are handled internally
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await expect(result.current.loadConversation("conv-err")).resolves.toBeUndefined();
|
await expect(result.current.loadConversation("conv-err")).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -419,19 +564,17 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
describe("startNewConversation", () => {
|
describe("startNewConversation", () => {
|
||||||
it("should reset to initial state", async () => {
|
it("should reset to initial state", async () => {
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
|
makeStreamSucceed(["Response"]);
|
||||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
// Send a message to have some state
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.messages.length).toBeGreaterThan(1);
|
expect(result.current.messages.length).toBeGreaterThan(1);
|
||||||
|
|
||||||
// Start new conversation
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.startNewConversation();
|
result.current.startNewConversation();
|
||||||
});
|
});
|
||||||
@@ -446,6 +589,7 @@ describe("useChat", () => {
|
|||||||
describe("clearError", () => {
|
describe("clearError", () => {
|
||||||
it("should clear error state", async () => {
|
it("should clear error state", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Test error"));
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Test error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
@@ -467,6 +611,7 @@ describe("useChat", () => {
|
|||||||
describe("error context logging", () => {
|
describe("error context logging", () => {
|
||||||
it("should log comprehensive error context when sendMessage fails", async () => {
|
it("should log comprehensive error context when sendMessage fails", async () => {
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("LLM timeout"));
|
mockSendChatMessage.mockRejectedValueOnce(new Error("LLM timeout"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat({ model: "llama3.2" }));
|
const { result } = renderHook(() => useChat({ model: "llama3.2" }));
|
||||||
@@ -489,6 +634,7 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
it("should truncate long message previews to 50 characters", async () => {
|
it("should truncate long message previews to 50 characters", async () => {
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Failed"));
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Failed"));
|
||||||
|
|
||||||
const longMessage = "A".repeat(100);
|
const longMessage = "A".repeat(100);
|
||||||
@@ -509,9 +655,10 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
it("should include message count in error context", async () => {
|
it("should include message count in error context", async () => {
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
|
||||||
// First successful message
|
// First successful message via streaming
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK"));
|
makeStreamSucceed(["OK"]);
|
||||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
@@ -520,14 +667,14 @@ describe("useChat", () => {
|
|||||||
await result.current.sendMessage("First");
|
await result.current.sendMessage("First");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Second message fails
|
// Second message: streaming fails, fallback fails
|
||||||
|
makeStreamFail();
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Fail"));
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Fail"));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Second");
|
await result.current.sendMessage("Second");
|
||||||
});
|
});
|
||||||
|
|
||||||
// messageCount should reflect messages including the new user message
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
"Failed to send chat message",
|
"Failed to send chat message",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -540,6 +687,7 @@ describe("useChat", () => {
|
|||||||
describe("LLM vs persistence error separation", () => {
|
describe("LLM vs persistence error separation", () => {
|
||||||
it("should show LLM error and add error message to chat when API fails", async () => {
|
it("should show LLM error and add error message to chat when API fails", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available"));
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
@@ -549,13 +697,29 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
||||||
// Should have welcome + user + error message
|
|
||||||
expect(result.current.messages).toHaveLength(3);
|
expect(result.current.messages).toHaveLength(3);
|
||||||
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep assistant message visible when save fails", async () => {
|
it("should keep assistant message visible when save fails (streaming path)", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
makeStreamSucceed(["Great answer!"]);
|
||||||
|
mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendMessage("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages).toHaveLength(3); // welcome + user + assistant
|
||||||
|
expect(result.current.messages[2]?.content).toBe("Great answer!");
|
||||||
|
expect(result.current.error).toContain("Message sent but failed to save");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep assistant message visible when save fails (fallback path)", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Great answer!"));
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Great answer!"));
|
||||||
mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost"));
|
mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost"));
|
||||||
|
|
||||||
@@ -565,16 +729,14 @@ describe("useChat", () => {
|
|||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assistant message should still be visible
|
expect(result.current.messages).toHaveLength(3);
|
||||||
expect(result.current.messages).toHaveLength(3); // welcome + user + assistant
|
|
||||||
expect(result.current.messages[2]?.content).toBe("Great answer!");
|
expect(result.current.messages[2]?.content).toBe("Great answer!");
|
||||||
|
|
||||||
// Error should indicate persistence failure
|
|
||||||
expect(result.current.error).toContain("Message sent but failed to save");
|
expect(result.current.error).toContain("Message sent but failed to save");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should log with PERSISTENCE_ERROR type when save fails", async () => {
|
it("should log with PERSISTENCE_ERROR type when save fails", async () => {
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
|
||||||
mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
|
mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
|
||||||
@@ -591,7 +753,6 @@ describe("useChat", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should NOT have logged as LLM_ERROR
|
|
||||||
const llmErrorCalls = consoleSpy.mock.calls.filter((call) => {
|
const llmErrorCalls = consoleSpy.mock.calls.filter((call) => {
|
||||||
const ctx: unknown = call[1];
|
const ctx: unknown = call[1];
|
||||||
return (
|
return (
|
||||||
@@ -606,8 +767,9 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
it("should use different user-facing messages for LLM vs save errors", async () => {
|
it("should use different user-facing messages for LLM vs save errors", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
|
||||||
// Test LLM error message
|
// LLM error path (streaming fails + fallback fails)
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Timeout"));
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Timeout"));
|
||||||
const { result: result1 } = renderHook(() => useChat());
|
const { result: result1 } = renderHook(() => useChat());
|
||||||
|
|
||||||
@@ -617,8 +779,8 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
const llmError = result1.current.error;
|
const llmError = result1.current.error;
|
||||||
|
|
||||||
// Test save error message
|
// Save error path (streaming succeeds, save fails)
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK"));
|
makeStreamSucceed(["OK"]);
|
||||||
mockCreateConversation.mockRejectedValueOnce(new Error("DB down"));
|
mockCreateConversation.mockRejectedValueOnce(new Error("DB down"));
|
||||||
const { result: result2 } = renderHook(() => useChat());
|
const { result: result2 } = renderHook(() => useChat());
|
||||||
|
|
||||||
@@ -628,7 +790,6 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
const saveError = result2.current.error;
|
const saveError = result2.current.error;
|
||||||
|
|
||||||
// They should be different
|
|
||||||
expect(llmError).toBe("Unable to send message. Please try again.");
|
expect(llmError).toBe("Unable to send message. Please try again.");
|
||||||
expect(saveError).toContain("Message sent but failed to save");
|
expect(saveError).toContain("Message sent but failed to save");
|
||||||
expect(llmError).not.toEqual(saveError);
|
expect(llmError).not.toEqual(saveError);
|
||||||
@@ -636,6 +797,7 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
it("should handle non-Error throws from LLM API", async () => {
|
it("should handle non-Error throws from LLM API", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockRejectedValueOnce("string error");
|
mockSendChatMessage.mockRejectedValueOnce("string error");
|
||||||
|
|
||||||
const onError = vi.fn();
|
const onError = vi.fn();
|
||||||
@@ -652,7 +814,8 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
it("should handle non-Error throws from persistence layer", async () => {
|
it("should handle non-Error throws from persistence layer", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK"));
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
makeStreamSucceed(["OK"]);
|
||||||
mockCreateConversation.mockRejectedValueOnce("DB string error");
|
mockCreateConversation.mockRejectedValueOnce("DB string error");
|
||||||
|
|
||||||
const onError = vi.fn();
|
const onError = vi.fn();
|
||||||
@@ -662,7 +825,6 @@ describe("useChat", () => {
|
|||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assistant message should still be visible
|
|
||||||
expect(result.current.messages[2]?.content).toBe("OK");
|
expect(result.current.messages[2]?.content).toBe("OK");
|
||||||
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
||||||
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
||||||
@@ -670,8 +832,9 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
it("should handle updateConversation failure for existing conversations", async () => {
|
it("should handle updateConversation failure for existing conversations", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
|
||||||
// First message succeeds and creates conversation
|
// First message via fallback
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("First response"));
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("First response"));
|
||||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||||
|
|
||||||
@@ -683,7 +846,8 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
expect(result.current.conversationId).toBe("conv-1");
|
expect(result.current.conversationId).toBe("conv-1");
|
||||||
|
|
||||||
// Second message succeeds but updateConversation fails
|
// Second message via fallback, updateConversation fails
|
||||||
|
makeStreamFail();
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Second response"));
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Second response"));
|
||||||
mockUpdateConversation.mockRejectedValueOnce(new Error("Connection reset"));
|
mockUpdateConversation.mockRejectedValueOnce(new Error("Connection reset"));
|
||||||
|
|
||||||
@@ -691,8 +855,10 @@ describe("useChat", () => {
|
|||||||
await result.current.sendMessage("Second");
|
await result.current.sendMessage("Second");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assistant message should still be visible
|
const assistantMessages = result.current.messages.filter(
|
||||||
expect(result.current.messages[4]?.content).toBe("Second response");
|
(m) => m.role === "assistant" && m.id !== "welcome"
|
||||||
|
);
|
||||||
|
expect(assistantMessages[assistantMessages.length - 1]?.content).toBe("Second response");
|
||||||
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
import { sendChatMessage, type ChatMessage as ApiChatMessage } from "@/lib/api/chat";
|
import {
|
||||||
|
sendChatMessage,
|
||||||
|
streamChatMessage,
|
||||||
|
type ChatMessage as ApiChatMessage,
|
||||||
|
} from "@/lib/api/chat";
|
||||||
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
|
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
|
||||||
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
|
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
|
||||||
|
|
||||||
@@ -33,10 +37,12 @@ export interface UseChatOptions {
|
|||||||
export interface UseChatReturn {
|
export interface UseChatReturn {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
conversationId: string | null;
|
conversationId: string | null;
|
||||||
conversationTitle: string | null;
|
conversationTitle: string | null;
|
||||||
sendMessage: (content: string) => Promise<void>;
|
sendMessage: (content: string) => Promise<void>;
|
||||||
|
abortStream: () => void;
|
||||||
loadConversation: (ideaId: string) => Promise<void>;
|
loadConversation: (ideaId: string) => Promise<void>;
|
||||||
startNewConversation: (projectId?: string | null) => void;
|
startNewConversation: (projectId?: string | null) => void;
|
||||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||||
@@ -66,6 +72,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
|
|
||||||
const [messages, setMessages] = useState<Message[]>([WELCOME_MESSAGE]);
|
const [messages, setMessages] = useState<Message[]>([WELCOME_MESSAGE]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||||
const [conversationTitle, setConversationTitle] = useState<string | null>(null);
|
const [conversationTitle, setConversationTitle] = useState<string | null>(null);
|
||||||
@@ -78,6 +85,16 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
const messagesRef = useRef<Message[]>(messages);
|
const messagesRef = useRef<Message[]>(messages);
|
||||||
messagesRef.current = messages;
|
messagesRef.current = messages;
|
||||||
|
|
||||||
|
// AbortController ref for the active stream
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Track conversation state in refs to avoid stale closures in streaming callbacks
|
||||||
|
const conversationIdRef = useRef<string | null>(conversationId);
|
||||||
|
conversationIdRef.current = conversationId;
|
||||||
|
|
||||||
|
const conversationTitleRef = useRef<string | null>(conversationTitle);
|
||||||
|
conversationTitleRef.current = conversationTitle;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert our Message format to API ChatMessage format
|
* Convert our Message format to API ChatMessage format
|
||||||
*/
|
*/
|
||||||
@@ -119,44 +136,57 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save conversation to backend
|
* Save conversation to backend.
|
||||||
|
* Uses refs for conversation state to avoid stale closures in streaming callbacks.
|
||||||
*/
|
*/
|
||||||
const saveConversation = useCallback(
|
const saveConversation = useCallback(
|
||||||
async (msgs: Message[], title: string): Promise<string> => {
|
async (msgs: Message[], title: string): Promise<string> => {
|
||||||
const content = serializeMessages(msgs);
|
const content = serializeMessages(msgs);
|
||||||
|
const currentConvId = conversationIdRef.current;
|
||||||
|
|
||||||
if (conversationId) {
|
if (currentConvId) {
|
||||||
// Update existing conversation
|
await updateConversation(currentConvId, content, title);
|
||||||
await updateConversation(conversationId, content, title);
|
return currentConvId;
|
||||||
return conversationId;
|
|
||||||
} else {
|
} else {
|
||||||
// Create new conversation
|
|
||||||
const idea = await createConversation(title, content, projectIdRef.current ?? undefined);
|
const idea = await createConversation(title, content, projectIdRef.current ?? undefined);
|
||||||
setConversationId(idea.id);
|
setConversationId(idea.id);
|
||||||
setConversationTitle(title);
|
setConversationTitle(title);
|
||||||
|
conversationIdRef.current = idea.id;
|
||||||
|
conversationTitleRef.current = title;
|
||||||
return idea.id;
|
return idea.id;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[conversationId, serializeMessages]
|
[serializeMessages]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message to the LLM and save the conversation
|
* Abort an active stream
|
||||||
|
*/
|
||||||
|
const abortStream = useCallback((): void => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
setIsStreaming(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to the LLM using streaming, with fallback to non-streaming
|
||||||
*/
|
*/
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
async (content: string): Promise<void> => {
|
async (content: string): Promise<void> => {
|
||||||
if (!content.trim() || isLoading) {
|
if (!content.trim() || isLoading || isStreaming) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userMessage: Message = {
|
const userMessage: Message = {
|
||||||
id: `user-${Date.now().toString()}`,
|
id: `user-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
role: "user",
|
role: "user",
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add user message immediately using functional update
|
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const updated = [...prev, userMessage];
|
const updated = [...prev, userMessage];
|
||||||
messagesRef.current = updated;
|
messagesRef.current = updated;
|
||||||
@@ -165,95 +195,186 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
const assistantMessageId = `assistant-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const placeholderMessage: Message = {
|
||||||
|
id: assistantMessageId,
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentMessages = messagesRef.current;
|
||||||
|
const apiMessages = convertToApiMessages(currentMessages);
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
model,
|
||||||
|
messages: apiMessages,
|
||||||
|
...(temperature !== undefined && { temperature }),
|
||||||
|
...(maxTokens !== undefined && { maxTokens }),
|
||||||
|
...(systemPrompt !== undefined && { systemPrompt }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortControllerRef.current = controller;
|
||||||
|
|
||||||
|
let streamingSucceeded = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Prepare API request - use ref to get current messages (prevents stale closure)
|
await new Promise<void>((resolve, reject) => {
|
||||||
const currentMessages = messagesRef.current;
|
let hasReceivedData = false;
|
||||||
const apiMessages = convertToApiMessages(currentMessages);
|
|
||||||
|
|
||||||
const request = {
|
streamChatMessage(
|
||||||
model,
|
request,
|
||||||
messages: apiMessages,
|
(chunk: string) => {
|
||||||
...(temperature !== undefined && { temperature }),
|
if (!hasReceivedData) {
|
||||||
...(maxTokens !== undefined && { maxTokens }),
|
hasReceivedData = true;
|
||||||
...(systemPrompt !== undefined && { systemPrompt }),
|
setIsLoading(false);
|
||||||
};
|
setIsStreaming(true);
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev, { ...placeholderMessage }];
|
||||||
|
messagesRef.current = updated;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Call LLM API
|
setMessages((prev) => {
|
||||||
const response = await sendChatMessage(request);
|
const updated = prev.map((msg) =>
|
||||||
|
msg.id === assistantMessageId ? { ...msg, content: msg.content + chunk } : msg
|
||||||
// Create assistant message
|
);
|
||||||
const assistantMessage: Message = {
|
messagesRef.current = updated;
|
||||||
id: `assistant-${Date.now().toString()}`,
|
return updated;
|
||||||
role: "assistant",
|
});
|
||||||
content: response.message.content,
|
},
|
||||||
createdAt: new Date().toISOString(),
|
() => {
|
||||||
model: response.model,
|
streamingSucceeded = true;
|
||||||
promptTokens: response.promptEvalCount ?? 0,
|
setIsStreaming(false);
|
||||||
completionTokens: response.evalCount ?? 0,
|
abortControllerRef.current = null;
|
||||||
totalTokens: (response.promptEvalCount ?? 0) + (response.evalCount ?? 0),
|
resolve();
|
||||||
};
|
},
|
||||||
|
(err: Error) => {
|
||||||
// Add assistant message using functional update
|
reject(err);
|
||||||
let finalMessages: Message[] = [];
|
},
|
||||||
setMessages((prev) => {
|
controller.signal
|
||||||
finalMessages = [...prev, assistantMessage];
|
);
|
||||||
messagesRef.current = finalMessages;
|
|
||||||
return finalMessages;
|
|
||||||
});
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
setIsStreaming(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
|
||||||
// Generate title from first user message if this is a new conversation
|
// Remove placeholder if no content was received
|
||||||
const isFirstMessage =
|
setMessages((prev) => {
|
||||||
!conversationId && finalMessages.filter((m) => m.role === "user").length === 1;
|
const assistantMsg = prev.find((m) => m.id === assistantMessageId);
|
||||||
const title = isFirstMessage
|
if (assistantMsg?.content === "") {
|
||||||
? generateTitle(content)
|
const updated = prev.filter((m) => m.id !== assistantMessageId);
|
||||||
: (conversationTitle ?? "Chat Conversation");
|
messagesRef.current = updated;
|
||||||
|
return updated;
|
||||||
// Save conversation (separate error handling from LLM errors)
|
}
|
||||||
try {
|
messagesRef.current = prev;
|
||||||
await saveConversation(finalMessages, title);
|
return prev;
|
||||||
} catch (saveErr) {
|
|
||||||
const saveErrorMsg =
|
|
||||||
saveErr instanceof Error ? saveErr.message : "Unknown persistence error";
|
|
||||||
setError("Message sent but failed to save. Please try again.");
|
|
||||||
onError?.(saveErr instanceof Error ? saveErr : new Error(saveErrorMsg));
|
|
||||||
console.error("Failed to save conversation", {
|
|
||||||
error: saveErr,
|
|
||||||
errorType: "PERSISTENCE_ERROR",
|
|
||||||
conversationId,
|
|
||||||
detail: saveErrorMsg,
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
const errorMsg = err instanceof Error ? err.message : "Failed to send message";
|
// Streaming failed — fall back to non-streaming
|
||||||
setError("Unable to send message. Please try again.");
|
console.warn("Streaming failed, falling back to non-streaming", {
|
||||||
onError?.(err instanceof Error ? err : new Error(errorMsg));
|
error: err instanceof Error ? err : new Error(String(err)),
|
||||||
console.error("Failed to send chat message", {
|
|
||||||
error: err,
|
|
||||||
errorType: "LLM_ERROR",
|
|
||||||
conversationId,
|
|
||||||
messageLength: content.length,
|
|
||||||
messagePreview: content.substring(0, 50),
|
|
||||||
model,
|
|
||||||
messageCount: messagesRef.current.length,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add error message to chat
|
setMessages((prev) => {
|
||||||
const errorMessage: Message = {
|
const withoutPlaceholder = prev.filter((m) => m.id !== assistantMessageId);
|
||||||
id: `error-${String(Date.now())}`,
|
messagesRef.current = withoutPlaceholder;
|
||||||
role: "assistant",
|
return withoutPlaceholder;
|
||||||
content: "Something went wrong. Please try again.",
|
});
|
||||||
createdAt: new Date().toISOString(),
|
setIsStreaming(false);
|
||||||
};
|
|
||||||
setMessages((prev) => [...prev, errorMessage]);
|
try {
|
||||||
} finally {
|
const response = await sendChatMessage(request);
|
||||||
setIsLoading(false);
|
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: `assistant-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
role: "assistant",
|
||||||
|
content: response.message.content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
model: response.model,
|
||||||
|
promptTokens: response.promptEvalCount ?? 0,
|
||||||
|
completionTokens: response.evalCount ?? 0,
|
||||||
|
totalTokens: (response.promptEvalCount ?? 0) + (response.evalCount ?? 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev, assistantMessage];
|
||||||
|
messagesRef.current = updated;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
streamingSucceeded = true;
|
||||||
|
} catch (fallbackErr: unknown) {
|
||||||
|
const errorMsg =
|
||||||
|
fallbackErr instanceof Error ? fallbackErr.message : "Failed to send message";
|
||||||
|
setError("Unable to send message. Please try again.");
|
||||||
|
onError?.(fallbackErr instanceof Error ? fallbackErr : new Error(errorMsg));
|
||||||
|
console.error("Failed to send chat message", {
|
||||||
|
error: fallbackErr,
|
||||||
|
errorType: "LLM_ERROR",
|
||||||
|
conversationId: conversationIdRef.current,
|
||||||
|
messageLength: content.length,
|
||||||
|
messagePreview: content.substring(0, 50),
|
||||||
|
model,
|
||||||
|
messageCount: messagesRef.current.length,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMessage: Message = {
|
||||||
|
id: `error-${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
role: "assistant",
|
||||||
|
content: "Something went wrong. Please try again.",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev, errorMessage];
|
||||||
|
messagesRef.current = updated;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
if (!streamingSucceeded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalMessages = messagesRef.current;
|
||||||
|
|
||||||
|
const isFirstMessage =
|
||||||
|
!conversationIdRef.current && finalMessages.filter((m) => m.role === "user").length === 1;
|
||||||
|
const title = isFirstMessage
|
||||||
|
? generateTitle(content)
|
||||||
|
: (conversationTitleRef.current ?? "Chat Conversation");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveConversation(finalMessages, title);
|
||||||
|
} catch (saveErr) {
|
||||||
|
const saveErrorMsg =
|
||||||
|
saveErr instanceof Error ? saveErr.message : "Unknown persistence error";
|
||||||
|
setError("Message sent but failed to save. Please try again.");
|
||||||
|
onError?.(saveErr instanceof Error ? saveErr : new Error(saveErrorMsg));
|
||||||
|
console.error("Failed to save conversation", {
|
||||||
|
error: saveErr,
|
||||||
|
errorType: "PERSISTENCE_ERROR",
|
||||||
|
conversationId: conversationIdRef.current,
|
||||||
|
detail: saveErrorMsg,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
isLoading,
|
isLoading,
|
||||||
conversationId,
|
isStreaming,
|
||||||
conversationTitle,
|
|
||||||
model,
|
model,
|
||||||
temperature,
|
temperature,
|
||||||
maxTokens,
|
maxTokens,
|
||||||
@@ -280,6 +401,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
setMessages(msgs);
|
setMessages(msgs);
|
||||||
setConversationId(idea.id);
|
setConversationId(idea.id);
|
||||||
setConversationTitle(idea.title ?? null);
|
setConversationTitle(idea.title ?? null);
|
||||||
|
conversationIdRef.current = idea.id;
|
||||||
|
conversationTitleRef.current = idea.title ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMsg = err instanceof Error ? err.message : "Failed to load conversation";
|
const errorMsg = err instanceof Error ? err.message : "Failed to load conversation";
|
||||||
setError("Unable to load conversation. Please try again.");
|
setError("Unable to load conversation. Please try again.");
|
||||||
@@ -305,6 +428,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
setConversationId(null);
|
setConversationId(null);
|
||||||
setConversationTitle(null);
|
setConversationTitle(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
conversationIdRef.current = null;
|
||||||
|
conversationTitleRef.current = null;
|
||||||
projectIdRef.current = newProjectId ?? null;
|
projectIdRef.current = newProjectId ?? null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -318,10 +443,12 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isStreaming,
|
||||||
error,
|
error,
|
||||||
conversationId,
|
conversationId,
|
||||||
conversationTitle,
|
conversationTitle,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
abortStream,
|
||||||
loadConversation,
|
loadConversation,
|
||||||
startNewConversation,
|
startNewConversation,
|
||||||
setMessages,
|
setMessages,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* Handles LLM chat interactions via /api/llm/chat
|
* Handles LLM chat interactions via /api/llm/chat
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiPost } from "./client";
|
import { apiPost, fetchCsrfToken, getCsrfToken } from "./client";
|
||||||
|
import { API_BASE_URL } from "../config";
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: "system" | "user" | "assistant";
|
role: "system" | "user" | "assistant";
|
||||||
@@ -31,6 +32,19 @@ export interface ChatResponse {
|
|||||||
evalCount?: number;
|
evalCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed SSE data chunk from the LLM stream
|
||||||
|
*/
|
||||||
|
interface SseChunk {
|
||||||
|
error?: string;
|
||||||
|
message?: {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
model?: string;
|
||||||
|
done?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a chat message to the LLM
|
* Send a chat message to the LLM
|
||||||
*/
|
*/
|
||||||
@@ -39,19 +53,122 @@ export async function sendChatMessage(request: ChatRequest): Promise<ChatRespons
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream a chat message from the LLM (not implemented yet)
|
* Get or refresh the CSRF token for streaming requests.
|
||||||
* TODO: Implement streaming support
|
*/
|
||||||
|
async function ensureCsrfTokenForStream(): Promise<string> {
|
||||||
|
const existing = getCsrfToken();
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return fetchCsrfToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a chat message from the LLM using SSE over fetch.
|
||||||
|
*
|
||||||
|
* The backend accepts stream: true in the request body and responds with
|
||||||
|
* Server-Sent Events:
|
||||||
|
* data: {"message":{"content":"token"},...}\n\n for each token
|
||||||
|
* data: [DONE]\n\n when the stream is complete
|
||||||
|
* data: {"error":"message"}\n\n on error
|
||||||
|
*
|
||||||
|
* @param request - Chat request (stream field will be forced to true)
|
||||||
|
* @param onChunk - Called with each token string as it arrives
|
||||||
|
* @param onComplete - Called when the stream finishes successfully
|
||||||
|
* @param onError - Called if the stream encounters an error
|
||||||
|
* @param signal - Optional AbortSignal for cancellation
|
||||||
*/
|
*/
|
||||||
export function streamChatMessage(
|
export function streamChatMessage(
|
||||||
request: ChatRequest,
|
request: ChatRequest,
|
||||||
onChunk: (chunk: string) => void,
|
onChunk: (chunk: string) => void,
|
||||||
onComplete: () => void,
|
onComplete: () => void,
|
||||||
onError: (error: Error) => void
|
onError: (error: Error) => void,
|
||||||
|
signal?: AbortSignal
|
||||||
): void {
|
): void {
|
||||||
// Streaming implementation would go here
|
void (async (): Promise<void> => {
|
||||||
void request;
|
try {
|
||||||
void onChunk;
|
const csrfToken = await ensureCsrfTokenForStream();
|
||||||
void onComplete;
|
|
||||||
void onError;
|
const response = await fetch(`${API_BASE_URL}/api/llm/chat`, {
|
||||||
throw new Error("Streaming not implemented yet");
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": csrfToken,
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ ...request, stream: true }),
|
||||||
|
signal: signal ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => response.statusText);
|
||||||
|
throw new Error(`Stream request failed: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Response body is not readable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
let readerDone = false;
|
||||||
|
while (!readerDone) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
readerDone = done;
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// SSE messages are separated by double newlines
|
||||||
|
const parts = buffer.split("\n\n");
|
||||||
|
// Keep the last (potentially incomplete) part
|
||||||
|
buffer = parts.pop() ?? "";
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
for (const line of trimmed.split("\n")) {
|
||||||
|
if (!line.startsWith("data: ")) continue;
|
||||||
|
|
||||||
|
const data = line.slice("data: ".length).trim();
|
||||||
|
|
||||||
|
if (data === "[DONE]") {
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data) as SseChunk;
|
||||||
|
|
||||||
|
if (parsed.error) {
|
||||||
|
throw new Error(parsed.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.message?.content) {
|
||||||
|
onChunk(parsed.message.content);
|
||||||
|
}
|
||||||
|
} catch (parseErr) {
|
||||||
|
if (parseErr instanceof SyntaxError) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw parseErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Natural end of stream without [DONE]
|
||||||
|
onComplete();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,72 @@
|
|||||||
# Mission Manifest — MS18 Theme & Widget System
|
# Mission Manifest — MS19 Chat & Terminal System
|
||||||
|
|
||||||
> Persistent document tracking full mission scope, status, and session history.
|
> Persistent document tracking full mission scope, status, and session history.
|
||||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||||
|
|
||||||
## Mission
|
## Mission
|
||||||
|
|
||||||
**ID:** ms18-theme-widgets-20260223
|
**ID:** ms19-chat-terminal-20260225
|
||||||
**Statement:** Implement MS18 (Theme & Widget System) — multi-theme package system, customizable widget dashboard, WYSIWYG knowledge editor, and enhanced Kanban filtering
|
**Statement:** Implement MS19 (Chat & Terminal System) — real terminal with PTY backend, chat streaming, master chat polish, project-level orchestrator chat, and agent output integration
|
||||||
**Phase:** Planning
|
**Phase:** Planning
|
||||||
**Current Milestone:** MS18-ThemeWidgets
|
**Current Milestone:** MS19-ChatTerminal
|
||||||
**Progress:** 0 / 1 milestones
|
**Progress:** 0 / 1 milestones
|
||||||
**Status:** active
|
**Status:** planning
|
||||||
**Last Updated:** 2026-02-23T13:30Z
|
**Last Updated:** 2026-02-25T20:00Z
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
1. Theme system supports 5+ themes (dark, light, + 3 additional built-in)
|
1. Terminal panel has real xterm.js with PTY backend via WebSocket
|
||||||
2. Themes are defined as TypeScript packages with CSS variable overrides
|
2. Terminal supports multiple named sessions (create/close/rename tabs)
|
||||||
3. Theme selection UI in Settings with live preview swatches
|
3. Terminal sessions persist in PostgreSQL and recover on reconnect
|
||||||
4. UserPreference.theme persists selected theme across sessions
|
4. Chat streaming renders tokens in real-time via SSE
|
||||||
5. Dashboard uses customizable WidgetGrid (drag, resize, add, remove widgets)
|
5. Master chat sidebar accessible from any page (Cmd+Shift+J / Cmd+K)
|
||||||
6. Widget picker UI allows browsing and adding widgets from registry
|
6. Master chat supports model selection, temperature, conversation management
|
||||||
7. Per-widget configuration dialog (data source, filters, colors)
|
7. Project-level chat can trigger orchestrator actions (/spawn, /status, /jobs)
|
||||||
8. Layout save/load/rename via UserLayout API
|
8. Agent output from orchestrator viewable in terminal tabs
|
||||||
9. WYSIWYG editor (Tiptap) for knowledge entries with toolbar
|
9. All features support all 5 themes (Dark, Light, Nord, Dracula, Solarized)
|
||||||
10. Markdown ↔ rich text round-trip (import/export)
|
10. Lint, typecheck, and tests pass
|
||||||
11. Kanban board supports project-level and user-level filtering
|
11. Deployed and smoke-tested at mosaic.woltje.com
|
||||||
12. Kanban filter bar: project, assignee, priority, search
|
|
||||||
13. All features support all themes (dark/light + new themes)
|
## Existing Infrastructure
|
||||||
14. Lint, typecheck, and tests pass
|
|
||||||
15. Deployed and smoke-tested at mosaic.woltje.com
|
Key components already built that MS19 builds upon:
|
||||||
|
|
||||||
|
| Component | Status | Location |
|
||||||
|
| --------------------------------- | ------------------- | ------------------------------------ |
|
||||||
|
| ChatOverlay + ConversationSidebar | ~95% complete | `apps/web/src/components/chat/` |
|
||||||
|
| LLM Controller with SSE | Working | `apps/api/src/llm/` |
|
||||||
|
| WebSocket Gateway | Production | `apps/api/src/websocket/` |
|
||||||
|
| TerminalPanel UI (mock) | UI-only, no backend | `apps/web/src/components/terminal/` |
|
||||||
|
| Orchestrator proxy routes | Working | `apps/web/src/app/api/orchestrator/` |
|
||||||
|
| Speech Gateway (pattern ref) | Production | `apps/api/src/speech/` |
|
||||||
|
| Ideas API (chat persistence) | Working | `apps/api/src/ideas/` |
|
||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||||
| --- | ---- | --------------------- | ----------- | ------------------------- | ------------------------ | ------- | --------- |
|
| --- | ---- | ---------------------- | -------- | ------------------------- | ------------------------ | ---------- | --------- |
|
||||||
| 1 | MS18 | Theme & Widget System | not-started | per-task feature branches | #487,#488,#489,#490,#491 | — | — |
|
| 1 | MS19 | Chat & Terminal System | planning | per-task feature branches | #508,#509,#510,#511,#512 | 2026-02-25 | — |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
| Target | URL | Method |
|
| Target | URL | Method |
|
||||||
| ------- | ----------------- | -------------- |
|
| --------- | ----------------- | --------------------------- |
|
||||||
| Coolify | mosaic.woltje.com | CI/CD pipeline |
|
| Portainer | mosaic.woltje.com | CI/CD pipeline (Woodpecker) |
|
||||||
|
|
||||||
## Token Budget
|
## Token Budget
|
||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
| ------ | ----------------- |
|
| ------ | ----------------- |
|
||||||
| Budget | ~500K (estimated) |
|
| Budget | ~300K (estimated) |
|
||||||
| Used | 0 |
|
| Used | ~0K |
|
||||||
| Mode | normal |
|
| Mode | normal |
|
||||||
|
|
||||||
## Session History
|
## Session History
|
||||||
|
|
||||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||||
| ------- | --------------- | ----------------- | -------- | ------------ | ------------------- |
|
| ------- | --------------- | ----------------- | -------- | ------------ | ------------------- |
|
||||||
| S1 | Claude Opus 4.6 | 2026-02-23T13:30Z | — | — | Planning (PLAN-001) |
|
| S1 | Claude Opus 4.6 | 2026-02-25T20:00Z | — | — | Planning (PLAN-001) |
|
||||||
|
|
||||||
## Scratchpad
|
## Scratchpad
|
||||||
|
|
||||||
Path: `docs/scratchpads/ms18-theme-widgets-20260223.md`
|
Path: `docs/scratchpads/ms19-chat-terminal-20260225.md`
|
||||||
|
|||||||
121
docs/PRD.md
121
docs/PRD.md
@@ -50,7 +50,7 @@ Dashboard polish, task ingestion pipeline, agent cycle visibility, deploy + smok
|
|||||||
- Dashboard widgets wired to real API data (ActivityFeed, DashboardMetrics, OrchestratorSessions)
|
- Dashboard widgets wired to real API data (ActivityFeed, DashboardMetrics, OrchestratorSessions)
|
||||||
- WebSocket emits for job status/progress/step events
|
- WebSocket emits for job status/progress/step events
|
||||||
- Dashboard auto-refresh with polling + progress bars + step status indicators
|
- Dashboard auto-refresh with polling + progress bars + step status indicators
|
||||||
- Deployed to Coolify at mosaic.woltje.com, auth working via Authentik
|
- Deployed to mosaic.woltje.com, auth working via Authentik
|
||||||
- Release tag v0.1.0
|
- Release tag v0.1.0
|
||||||
|
|
||||||
### MS16+MS17-PagesDataIntegration (v0.1.1) — Complete
|
### MS16+MS17-PagesDataIntegration (v0.1.1) — Complete
|
||||||
@@ -69,6 +69,31 @@ All pages built + wired to real API data. PRs #470-484 (15 PRs). Issues #466-469
|
|||||||
- All 5125 tests passing, CI pipeline #585 green
|
- All 5125 tests passing, CI pipeline #585 green
|
||||||
- Deployed and smoke-tested at mosaic.woltje.com
|
- Deployed and smoke-tested at mosaic.woltje.com
|
||||||
|
|
||||||
|
### MS18-ThemeWidgets (v0.1.2) — Complete
|
||||||
|
|
||||||
|
Theme package system, widget registry, WYSIWYG editor, Kanban filtering. PRs #493-505. Issues #487-491.
|
||||||
|
|
||||||
|
- 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TypeScript theme packages
|
||||||
|
- ThemeProvider with dynamic CSS variable application and instant switching
|
||||||
|
- Theme selection UI in Settings with live preview swatches
|
||||||
|
- Widget definition registry with configurable sizing and schemas
|
||||||
|
- WidgetGrid dashboard with drag-and-drop layout (react-grid-layout)
|
||||||
|
- Widget picker drawer for adding widgets from registry
|
||||||
|
- Per-widget configuration dialog driven by configSchema
|
||||||
|
- Layout save/load/rename/delete via UserLayout API
|
||||||
|
- Tiptap WYSIWYG editor for knowledge entries with toolbar
|
||||||
|
- Markdown round-trip (import/export)
|
||||||
|
- Kanban board filtering by project, assignee, priority, search with URL persistence
|
||||||
|
- 1,195 web tests, 3,243 API tests passing
|
||||||
|
|
||||||
|
### Bugfix: API Global Prefix (post-MS18) — Complete
|
||||||
|
|
||||||
|
PR #507. Fixed systemic 404 on all data endpoints.
|
||||||
|
|
||||||
|
- Added `setGlobalPrefix("api")` to NestJS with exclusions for /health and /auth/\*
|
||||||
|
- Normalized 6 federation controllers to remove redundant api/ prefix
|
||||||
|
- Fixed rollup CVE (GHSA-mw96-cpmx-2vgc) via pnpm override
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
### In Scope (MS16+MS17 — Pages & Data Integration)
|
### In Scope (MS16+MS17 — Pages & Data Integration)
|
||||||
@@ -98,7 +123,7 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
|
|||||||
18. Team management with shared data spaces and chat rooms (MS20)
|
18. Team management with shared data spaces and chat rooms (MS20)
|
||||||
19. RBAC for file access, resources, models (MS20)
|
19. RBAC for file access, resources, models (MS20)
|
||||||
20. Federation: master-master and master-slave with key exchange (MS21)
|
20. Federation: master-master and master-slave with key exchange (MS21)
|
||||||
21. Federation testing: 3 instances on Coolify (woltje.com domain) (MS21)
|
21. Federation testing: 3 instances on Portainer (woltje.com domain) (MS21)
|
||||||
22. Agent task mapping configuration: system-level defaults, user-level overrides (MS22)
|
22. Agent task mapping configuration: system-level defaults, user-level overrides (MS22)
|
||||||
23. Telemetry: opt-out, customizable endpoint, sanitized data (MS22)
|
23. Telemetry: opt-out, customizable endpoint, sanitized data (MS22)
|
||||||
24. File manager with WYSIWYG editing: system/user/project levels (MS18)
|
24. File manager with WYSIWYG editing: system/user/project levels (MS18)
|
||||||
@@ -113,7 +138,7 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
|
|||||||
|
|
||||||
1. Mobile native app
|
1. Mobile native app
|
||||||
2. Third-party marketplace for themes/widgets (initial implementation is local package management only)
|
2. Third-party marketplace for themes/widgets (initial implementation is local package management only)
|
||||||
3. Production deployment to non-Coolify targets
|
3. Mobile native app deployment targets
|
||||||
4. Calendar system redesign (existing calendar implementation is retained)
|
4. Calendar system redesign (existing calendar implementation is retained)
|
||||||
|
|
||||||
## User/Stakeholder Requirements
|
## User/Stakeholder Requirements
|
||||||
@@ -257,21 +282,40 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
|
|||||||
- All pages must render real data from backend APIs
|
- All pages must render real data from backend APIs
|
||||||
- **Status: COMPLETE (MS16+MS17) — PRs #473-#476. 238+ lines of mock data removed.**
|
- **Status: COMPLETE (MS16+MS17) — PRs #473-#476. 238+ lines of mock data removed.**
|
||||||
|
|
||||||
### FR-016: Theme System (Future — MS18)
|
### FR-016: Theme System (MS18) — COMPLETE
|
||||||
|
|
||||||
- Support multiple themes beyond default dark/light
|
- 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TypeScript theme packages
|
||||||
- Themes are installable packages from Mosaic Stack repo
|
- ThemeProvider loads themes dynamically, applies CSS variables, instant switching
|
||||||
- Theme installation and selection from Settings page
|
- Theme selection UI in Settings with live preview swatches
|
||||||
- ASSUMPTION: Initial implementation supports dark/light from reference design. Multi-theme package system is a future milestone. Rationale: Foundation must be solid before extensibility.
|
- UserPreference.theme persists selection across sessions
|
||||||
|
- **Status: COMPLETE (MS18) — PRs #493-495**
|
||||||
|
|
||||||
### FR-017: Terminal Panel (Future — MS19)
|
### FR-017: Terminal Panel (MS19)
|
||||||
|
|
||||||
- Bottom drawer panel, toggleable from header and sidebar
|
- Bottom drawer panel, toggleable from header and sidebar
|
||||||
- Multiple tabs (Orchestrator, Shell, Build)
|
- Real xterm.js terminal with PTY backend via WebSocket
|
||||||
|
- Multiple tabs: shell sessions, orchestrator agent output, build logs
|
||||||
|
- Terminal session persistence (create/close/rename tabs)
|
||||||
- Smart terminal operating at project/orchestrator level
|
- Smart terminal operating at project/orchestrator level
|
||||||
- Global terminal for system interaction
|
- ASSUMPTION: Terminal backend uses node-pty for PTY management, communicating via WebSocket namespace (/terminal). Rationale: node-pty is the standard for Node.js terminal emulation, used by VS Code.
|
||||||
|
- ASSUMPTION: Terminal sessions are workspace-scoped and stored in PostgreSQL for recovery. Rationale: Consistent with existing workspace isolation pattern.
|
||||||
|
|
||||||
### FR-018: Settings Configuration (Future — MS20)
|
### FR-018: Chat Streaming & Master Chat (MS19)
|
||||||
|
|
||||||
|
- Complete SSE streaming for token-by-token chat rendering
|
||||||
|
- Master chat sidebar (ChatOverlay) polish: model selector, conversation search, keyboard shortcuts
|
||||||
|
- Chat persistence via Ideas API (already implemented)
|
||||||
|
- ASSUMPTION: Chat streaming uses existing SSE infrastructure in LLM controller. Frontend needs streamChatMessage() completion. Rationale: Backend SSE is already working, only frontend wiring is missing.
|
||||||
|
|
||||||
|
### FR-019: Project-Level Orchestrator Chat (MS19)
|
||||||
|
|
||||||
|
- Chat context scoped to active project
|
||||||
|
- Can trigger orchestrator actions: spawn agent, check status, view jobs
|
||||||
|
- Command prefix system (/spawn, /status, /jobs) parsed in chat
|
||||||
|
- Agent output viewable in terminal tabs
|
||||||
|
- ASSUMPTION: Orchestrator commands route through existing web proxy (/api/orchestrator/\*) to orchestrator service. Rationale: Proxy routes already exist and handle auth.
|
||||||
|
|
||||||
|
### FR-020: Settings Configuration (Future — MS20)
|
||||||
|
|
||||||
- All environment variables configurable via UI
|
- All environment variables configurable via UI
|
||||||
- Minimal launch env vars, rest configurable dynamically
|
- Minimal launch env vars, rest configurable dynamically
|
||||||
@@ -324,17 +368,46 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
|
|||||||
27. ~~Lint, typecheck, and tests pass~~ DONE
|
27. ~~Lint, typecheck, and tests pass~~ DONE
|
||||||
28. ~~Deployed and smoke-tested at mosaic.woltje.com~~ DONE
|
28. ~~Deployed and smoke-tested at mosaic.woltje.com~~ DONE
|
||||||
|
|
||||||
|
### MS18 — Theme & Widget System — COMPLETE
|
||||||
|
|
||||||
|
29. ~~5+ themes with live preview and instant switching~~ DONE
|
||||||
|
30. ~~Theme selection UI in Settings with swatches~~ DONE
|
||||||
|
31. ~~UserPreference.theme persists across sessions~~ DONE
|
||||||
|
32. ~~WidgetGrid dashboard with drag/resize/add/remove~~ DONE
|
||||||
|
33. ~~Widget picker UI from registry~~ DONE
|
||||||
|
34. ~~Per-widget configuration dialog~~ DONE
|
||||||
|
35. ~~Layout save/load/rename/delete via API~~ DONE
|
||||||
|
36. ~~Tiptap WYSIWYG editor for knowledge entries~~ DONE
|
||||||
|
37. ~~Markdown round-trip (import/export)~~ DONE
|
||||||
|
38. ~~Kanban filtering by project, assignee, priority, search~~ DONE
|
||||||
|
39. ~~All features support all themes~~ DONE
|
||||||
|
40. ~~Lint, typecheck, tests pass~~ DONE
|
||||||
|
|
||||||
|
### MS19 — Chat & Terminal
|
||||||
|
|
||||||
|
41. Terminal panel has real xterm.js with PTY backend
|
||||||
|
42. Terminal supports multiple named sessions (tabs)
|
||||||
|
43. Terminal sessions persist and recover on reconnect
|
||||||
|
44. Chat streaming renders tokens in real-time (SSE)
|
||||||
|
45. Master chat sidebar accessible from any page (Cmd+Shift+J)
|
||||||
|
46. Master chat supports model selection and conversation management
|
||||||
|
47. Project-level chat can trigger orchestrator actions
|
||||||
|
48. Agent output viewable in terminal tabs
|
||||||
|
49. All features support all themes
|
||||||
|
50. Lint, typecheck, tests pass
|
||||||
|
51. Deployed and smoke-tested
|
||||||
|
|
||||||
### Full Project (All Milestones)
|
### Full Project (All Milestones)
|
||||||
|
|
||||||
29. jarvis user logs in via Authentik, has admin access to all pages
|
52. jarvis user logs in via Authentik, has admin access to all pages
|
||||||
30. jarvis-user has standard access at lower permission level
|
53. jarvis-user has standard access at lower permission level
|
||||||
31. Break-glass user has access without Authentik
|
54. Break-glass user has access without Authentik
|
||||||
32. Three Mosaic Stack instances on Coolify with federation testing
|
55. Three Mosaic Stack instances on Portainer with federation testing
|
||||||
33. Playwright tests confirm all pages, functions, theming work
|
56. Playwright tests confirm all pages, functions, theming work
|
||||||
34. No errors during site navigation
|
57. No errors during site navigation
|
||||||
35. API documented via Swagger with proper auth gating
|
58. API documented via Swagger with proper auth gating
|
||||||
36. Telemetry working locally with wide-event logging
|
59. Telemetry working locally with wide-event logging
|
||||||
37. Mosaic Telemetry properly reporting to telemetry endpoint
|
60. Mosaic Telemetry properly reporting to telemetry endpoint
|
||||||
|
|
||||||
## Constraints and Dependencies
|
## Constraints and Dependencies
|
||||||
|
|
||||||
@@ -343,7 +416,7 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
|
|||||||
3. BetterAuth for authentication — must maintain existing auth flow
|
3. BetterAuth for authentication — must maintain existing auth flow
|
||||||
4. Authentik as IdP at auth.diversecanvas.com — must remain operational
|
4. Authentik as IdP at auth.diversecanvas.com — must remain operational
|
||||||
5. PostgreSQL 17 with Prisma — all settings stored in DB
|
5. PostgreSQL 17 with Prisma — all settings stored in DB
|
||||||
6. Coolify for deployment — 3 instances needed for federation testing
|
6. Portainer for deployment — 3 instances needed for federation testing
|
||||||
7. packages/ui is shared across apps — changes affect all consumers
|
7. packages/ui is shared across apps — changes affect all consumers
|
||||||
8. Backend API modules already exist for all page data needs — no new API endpoints required for MS16+MS17 scope
|
8. Backend API modules already exist for all page data needs — no new API endpoints required for MS16+MS17 scope
|
||||||
|
|
||||||
@@ -380,6 +453,8 @@ These 19 NestJS modules are already implemented with Prisma and available for fr
|
|||||||
| Credentials | `/api/credentials` | Encrypted storage |
|
| Credentials | `/api/credentials` | Encrypted storage |
|
||||||
| Brain/AI | `/api/brain` | Query/search |
|
| Brain/AI | `/api/brain` | Query/search |
|
||||||
| WebSocket | Real-time | Event broadcasting |
|
| WebSocket | Real-time | Event broadcasting |
|
||||||
|
| LLM | `/api/llm/chat` | Chat + SSE streaming |
|
||||||
|
| Orchestrator Proxy | `/api/orchestrator/*` | Agent mgmt proxy |
|
||||||
| Telemetry | Internal | Logging/monitoring |
|
| Telemetry | Internal | Logging/monitoring |
|
||||||
|
|
||||||
## Testing and Verification
|
## Testing and Verification
|
||||||
@@ -400,7 +475,7 @@ These 19 NestJS modules are already implemented with Prisma and available for fr
|
|||||||
| MS15-DashboardShell | 0.0.15 | Design system + app shell + dashboard page | COMPLETE |
|
| MS15-DashboardShell | 0.0.15 | Design system + app shell + dashboard page | COMPLETE |
|
||||||
| Go-Live MVP | 0.1.0 | Dashboard polish, ingestion, agent visibility, deploy | COMPLETE |
|
| Go-Live MVP | 0.1.0 | Dashboard polish, ingestion, agent visibility, deploy | COMPLETE |
|
||||||
| MS16+MS17-PagesDataIntegration | 0.1.1 | All pages built + wired to real API data | COMPLETE |
|
| MS16+MS17-PagesDataIntegration | 0.1.1 | All pages built + wired to real API data | COMPLETE |
|
||||||
| MS18-ThemeWidgets | 0.1.2 | Theme package system, widget registry, dashboard customization | IN PROGRESS |
|
| MS18-ThemeWidgets | 0.1.2 | Theme package system, widget registry, WYSIWYG, Kanban filtering | COMPLETE |
|
||||||
| MS19-ChatTerminal | 0.1.x | Global terminal, project chat, master chat session | NOT STARTED |
|
| MS19-ChatTerminal | 0.1.x | Global terminal, project chat, master chat session | NOT STARTED |
|
||||||
| MS20-MultiTenant | 0.2.0 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth | NOT STARTED |
|
| MS20-MultiTenant | 0.2.0 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth | NOT STARTED |
|
||||||
| MS21-Federation | 0.2.x | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
|
| MS21-Federation | 0.2.x | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
|
||||||
|
|||||||
@@ -1,34 +1,53 @@
|
|||||||
# Tasks — MS18 Theme & Widget System
|
# Tasks — MS19 Chat & Terminal System
|
||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
|
||||||
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||||
| ----------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---- | -------------------------------- | ------------------------------------------------------ | ------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ------------------------------------------ |
|
| ----------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------- | ------------------------------ | ----------------------------------------------- | ----------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ---------------------------------------------------------------- |
|
||||||
| TW-PLAN-001 | done | Plan MS18 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | TW-THM-001,TW-WDG-001,TW-EDT-001,TW-KBN-001 | orchestrator | 2026-02-23 | 2026-02-23 | 15K | ~12K | Planning complete, all artifacts committed |
|
| CT-PLAN-001 | done | Plan MS19 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | CT-TERM-001,CT-TERM-002,CT-CHAT-001,CT-CHAT-002 | orchestrator | 2026-02-25 | 2026-02-25 | 15K | ~15K | Planning complete |
|
||||||
| TW-THM-001 | done | Theme architecture — Create theme definition interface, theme registry, and 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TS files | #487 | web | feat/ms18-theme-architecture | TW-PLAN-001 | TW-THM-002,TW-THM-003 | worker | 2026-02-23 | 2026-02-23 | 30K | ~15K | PR #493 merged |
|
| CT-TERM-001 | not-started | Terminal WebSocket gateway & PTY session service — NestJS gateway (namespace: /terminal), node-pty spawn/kill/resize, workspace-scoped rooms, auth via token | #508 | api | feat/ms19-terminal-gateway | CT-PLAN-001 | CT-TERM-003,CT-TERM-004,CT-ORCH-002 | | | | 30K | | Follow speech gateway pattern |
|
||||||
| TW-THM-002 | done | ThemeProvider upgrade — Load themes dynamically from registry, apply CSS variables, support instant theme switching without page reload | #487 | web | feat/ms18-theme-provider-upgrade | TW-THM-001 | TW-THM-003,TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~12K | PR #494 merged |
|
| CT-TERM-002 | not-started | Terminal session persistence — Prisma model (TerminalSession: id, workspaceId, name, status, createdAt, closedAt), migration, CRUD service | #508 | api | feat/ms19-terminal-persistence | CT-PLAN-001 | CT-TERM-004 | | | | 15K | | |
|
||||||
| TW-THM-003 | done | Theme selection UI — Settings page section with theme browser, live preview swatches, persist selection to UserPreference.theme via API | #487 | web | feat/ms18-theme-selection-ui | TW-THM-001,TW-THM-002 | TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~10K | PR #495 merged |
|
| CT-TERM-003 | not-started | xterm.js integration — Replace mock TerminalPanel with real xterm.js, WebSocket connection to /terminal namespace, resize handling, copy/paste, theme support | #509 | web | feat/ms19-xterm-integration | CT-TERM-001 | CT-TERM-004 | | | | 30K | | Install @xterm/xterm + @xterm/addon-fit + @xterm/addon-web-links |
|
||||||
| TW-WDG-001 | done | Widget definition seeding — Seed 7 existing widgets into widget_definitions table with correct sizing constraints and configSchema | #488 | api | feat/ms18-widget-seed | TW-PLAN-001 | TW-WDG-002 | worker | 2026-02-23 | 2026-02-23 | 15K | ~8K | PR #496 merged |
|
| CT-TERM-004 | not-started | Terminal tab management — Multiple named sessions, create/close/rename tabs, tab switching, session list from API, reconnect on page reload | #509 | web | feat/ms19-terminal-tabs | CT-TERM-001,CT-TERM-002,CT-TERM-003 | CT-VER-001 | | | | 20K | | |
|
||||||
| TW-WDG-002 | done | Dashboard → WidgetGrid migration — Replace hardcoded dashboard layout with WidgetGrid, load/save layout via UserLayout API, default layout on first visit | #488 | web | feat/ms18-widget-grid-migration | TW-WDG-001 | TW-WDG-003,TW-WDG-004,TW-WDG-005 | worker | 2026-02-23 | 2026-02-23 | 40K | ~20K | PR #497 merged |
|
| CT-CHAT-001 | not-started | Complete SSE chat streaming — Wire streamChatMessage() in frontend, token-by-token rendering in MessageList, streaming state indicators, abort/cancel support | #510 | web | feat/ms19-chat-streaming | CT-PLAN-001 | CT-CHAT-002,CT-ORCH-001 | | | | 25K | | Backend SSE already works, frontend TODO |
|
||||||
| TW-WDG-003 | done | Widget picker UI — Drawer/dialog to browse available widgets from registry, preview size/description, add to dashboard | #488 | web | feat/ms18-widget-picker | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 25K | ~12K | PR #498 merged |
|
| CT-CHAT-002 | not-started | Master chat polish — Model selector dropdown, temperature/params config, conversation search in sidebar, keyboard shortcut improvements, empty state design | #510 | web | feat/ms19-chat-polish | CT-CHAT-001 | CT-VER-001 | | | | 15K | | ChatOverlay ~95% done, needs finishing touches |
|
||||||
| TW-WDG-004 | done | Widget configuration UI — Per-widget settings dialog using configSchema, configure data source/filters/colors/title | #488 | web | feat/ms18-layout-management | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~8K | PR #499 merged (bundled with WDG-005) |
|
| CT-ORCH-001 | not-started | Project-level orchestrator chat — Chat context scoped to project, command prefix parsing (/spawn, /status, /jobs, /kill), route commands through orchestrator proxy, display structured responses | #511 | web | feat/ms19-orchestrator-chat | CT-CHAT-001 | CT-ORCH-002,CT-VER-001 | | | | 30K | | Uses existing /api/orchestrator/\* proxy |
|
||||||
| TW-WDG-005 | done | Layout management UI — Save/rename/switch/delete layouts, reset to default. UI controls in dashboard header area | #488 | web | feat/ms18-layout-management | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 20K | ~8K | PR #499 merged (bundled with WDG-004) |
|
| CT-ORCH-002 | not-started | Agent output in terminal — View orchestrator agent sessions as terminal tabs, stream agent stdout/stderr via SSE (/agents/events), agent lifecycle indicators (spawning/running/done) | #511 | web | feat/ms19-agent-terminal | CT-TERM-001,CT-ORCH-001 | CT-VER-001 | | | | 25K | | Orchestrator already has SSE at /agents/events |
|
||||||
| TW-EDT-001 | done | Tiptap integration — Install @tiptap/react + extensions, build KnowledgeEditor component with toolbar (headings, bold, italic, lists, code, links, tables) | #489 | web | feat/ms18-tiptap-editor | TW-PLAN-001 | TW-EDT-002 | worker | 2026-02-23 | 2026-02-23 | 35K | ~12K | PR #500 merged |
|
| CT-VER-001 | not-started | Unit tests — Tests for terminal gateway, xterm component, chat streaming, orchestrator chat, agent terminal integration | #512 | web,api | feat/ms19-tests | CT-TERM-004,CT-CHAT-002,CT-ORCH-001,CT-ORCH-002 | CT-DOC-001 | | | | 20K | | |
|
||||||
| TW-EDT-002 | done | Markdown round-trip + File Manager integration — Import markdown to Tiptap, export to markdown + HTML. Replace textarea in knowledge create/edit | #489 | web | feat/ms18-markdown-roundtrip | TW-EDT-001 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~10K | PR #501 (pending) |
|
| CT-DOC-001 | not-started | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #512 | — | — | CT-VER-001 | CT-VER-002 | orchestrator | | | 10K | | |
|
||||||
| TW-KBN-001 | not-started | Kanban filtering — Add filter bar (project, assignee, priority, search). Support project-level and user-level views. URL param persistence | #490 | web | TBD | TW-PLAN-001 | TW-VER-001 | worker | — | — | 30K | — | |
|
| CT-VER-002 | not-started | Deploy + smoke test — Deploy to Portainer, verify terminal, chat streaming, orchestrator chat, agent output all functional | #512 | — | — | CT-DOC-001 | | orchestrator | | | 15K | | |
|
||||||
| TW-VER-001 | not-started | Tests — Unit tests for new components, update existing tests, fix any regressions | #491 | web | TBD | TW-WDG-003,TW-WDG-004,TW-WDG-005,TW-EDT-002,TW-KBN-001 | TW-VER-002,TW-DOC-001 | worker | — | — | 25K | — | |
|
|
||||||
| TW-VER-002 | not-started | Theme verification — Verify all 5 themes render correctly on all pages, no broken colors/contrast issues | #491 | web | TBD | TW-THM-003,TW-VER-001 | TW-DOC-001 | worker | — | — | 15K | — | |
|
|
||||||
| TW-DOC-001 | not-started | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #491 | — | — | TW-VER-001,TW-VER-002 | TW-VER-003 | orchestrator | — | — | 10K | — | |
|
|
||||||
| TW-VER-003 | not-started | Deploy to Coolify + smoke test — Deploy, verify themes/widgets/editor/kanban all functional, auth working, no console errors | #491 | — | — | TW-DOC-001 | | orchestrator | — | — | 15K | — | |
|
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
| ------------- | ---------------------------------------------------- |
|
| --------------- | ----------------- |
|
||||||
| Total tasks | 16 |
|
| Total tasks | 12 |
|
||||||
| Completed | 12 (PLAN-001, THM-001–003, WDG-001–005, EDT-001–002) |
|
| Completed | 1 (planning) |
|
||||||
| In Progress | 0 |
|
| In Progress | 0 |
|
||||||
| Remaining | 4 |
|
| Remaining | 11 |
|
||||||
| PRs merged | #493–#500, #501 (pending) |
|
| Estimated total | ~250K tokens |
|
||||||
| Issues closed | — |
|
| Milestone | MS19-ChatTerminal |
|
||||||
| Milestone | MS18-ThemeWidgets |
|
|
||||||
|
## Dependency Graph
|
||||||
|
|
||||||
|
```
|
||||||
|
PLAN-001 ──┬──→ TERM-001 ──┬──→ TERM-003 ──→ TERM-004 ──→ VER-001 ──→ DOC-001 ──→ VER-002
|
||||||
|
│ │ ↑
|
||||||
|
│ └──→ ORCH-002 ───────┘
|
||||||
|
│ ↑
|
||||||
|
├──→ TERM-002 ────────→ TERM-004
|
||||||
|
│
|
||||||
|
├──→ CHAT-001 ──┬──→ CHAT-002 ──→ VER-001
|
||||||
|
│ │
|
||||||
|
│ └──→ ORCH-001 ──→ ORCH-002
|
||||||
|
│
|
||||||
|
└──→ CHAT-002 (also depends on CHAT-001)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Execution Opportunities
|
||||||
|
|
||||||
|
- **Wave 1** (after PLAN-001): TERM-001 + TERM-002 + CHAT-001 can run in parallel (3 independent tracks)
|
||||||
|
- **Wave 2**: TERM-003 (after TERM-001) + CHAT-002 (after CHAT-001) + ORCH-001 (after CHAT-001) can overlap
|
||||||
|
- **Wave 3**: TERM-004 (after TERM-001+002+003) + ORCH-002 (after TERM-001+ORCH-001)
|
||||||
|
- **Wave 4**: VER-001 (after all implementation)
|
||||||
|
- **Wave 5**: DOC-001 → VER-002 (sequential)
|
||||||
|
|||||||
@@ -68,10 +68,12 @@ WYSIWYG editor library: agent's choice → Tiptap selected.
|
|||||||
|
|
||||||
## Session Log
|
## Session Log
|
||||||
|
|
||||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
| ------- | ---------- | --------- | ------------------------- | -------------------------------------------- |
|
| ------- | ---------- | --------- | ----------------------------- | ----------------------------------------------------------------- |
|
||||||
| S1 | 2026-02-23 | MS18 | PLAN-001 | Planning complete |
|
| S1 | 2026-02-23 | MS18 | PLAN-001 | Planning complete |
|
||||||
| S2 | 2026-02-23 | MS18 | THM-001, THM-002, THM-003 | Theme system complete — PRs #493, #494, #495 |
|
| S2 | 2026-02-23 | MS18 | THM-001, THM-002, THM-003 | Theme system complete — PRs #493, #494, #495 |
|
||||||
|
| S3 | 2026-02-23 | MS18 | WDG-001–005, EDT-001–002 | Widget + editor complete — PRs #496–#501 |
|
||||||
|
| S4 | 2026-02-24 | MS18 | KBN-001, VER-001–002, DOC-001 | Kanban filtering, tests, theme verification, docs — PRs #502–#504 |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
88
docs/scratchpads/ms19-chat-terminal-20260225.md
Normal file
88
docs/scratchpads/ms19-chat-terminal-20260225.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Mission Scratchpad — MS19 Chat & Terminal System
|
||||||
|
|
||||||
|
> Append-only log. NEVER delete entries. NEVER overwrite sections.
|
||||||
|
> This is the orchestrator's working memory across sessions.
|
||||||
|
|
||||||
|
## Original Mission Prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
Plan MS19+, update mission artifacts for Coolify → Portainer transition.
|
||||||
|
MS18 is complete. Coolify deprecated, Portainer migration in progress with another agent.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Planning Decisions
|
||||||
|
|
||||||
|
### 2026-02-25 — Infrastructure Assessment
|
||||||
|
|
||||||
|
**Existing chat infrastructure (~95% complete):**
|
||||||
|
|
||||||
|
- ChatOverlay.tsx, ConversationSidebar.tsx, ChatInput.tsx, MessageList.tsx
|
||||||
|
- useChat hook with conversation management and LLM interaction
|
||||||
|
- Backend LLM controller with SSE streaming support
|
||||||
|
- Providers: Ollama, Claude, OpenAI
|
||||||
|
- Chat persistence via Ideas API
|
||||||
|
|
||||||
|
**Existing terminal infrastructure (UI-only):**
|
||||||
|
|
||||||
|
- TerminalPanel.tsx with tabs, rich output styling, animations
|
||||||
|
- No backend connection — purely mock UI
|
||||||
|
|
||||||
|
**Existing orchestrator infrastructure:**
|
||||||
|
|
||||||
|
- Orchestrator service (NestJS, port 3002) with agent spawn/kill/status
|
||||||
|
- SSE stream at /agents/events for real-time agent status
|
||||||
|
- Web proxy routes at /api/orchestrator/\*
|
||||||
|
- Coordinator integration in API for job tracking
|
||||||
|
|
||||||
|
**Key pattern reference:**
|
||||||
|
|
||||||
|
- Speech gateway (`apps/api/src/speech/speech.gateway.ts`) shows namespace WebSocket pattern with session management
|
||||||
|
|
||||||
|
### 2026-02-25 — Architecture Decisions
|
||||||
|
|
||||||
|
**Decision: node-pty for terminal backend**
|
||||||
|
|
||||||
|
- Standard PTY management for Node.js (used by VS Code)
|
||||||
|
- Spawns real shell processes (bash/zsh)
|
||||||
|
- Handles resize, input/output streams
|
||||||
|
- WebSocket namespace /terminal for communication
|
||||||
|
|
||||||
|
**Decision: Terminal sessions in PostgreSQL**
|
||||||
|
|
||||||
|
- Consistent with workspace isolation pattern
|
||||||
|
- Prisma model: TerminalSession (id, workspaceId, name, status, createdAt, closedAt)
|
||||||
|
- Sessions survive page reload, recover on reconnect
|
||||||
|
|
||||||
|
**Decision: SSE for chat streaming (not WebSocket)**
|
||||||
|
|
||||||
|
- Backend already has SSE setup in LLM controller
|
||||||
|
- Only frontend wiring missing (streamChatMessage() is TODO)
|
||||||
|
- SSE is simpler and sufficient for unidirectional token streaming
|
||||||
|
|
||||||
|
**Decision: Orchestrator commands as chat prefixes**
|
||||||
|
|
||||||
|
- /spawn, /status, /jobs, /kill parsed in frontend
|
||||||
|
- Route through existing /api/orchestrator/\* proxy
|
||||||
|
- Display structured responses inline in chat
|
||||||
|
|
||||||
|
### 2026-02-25 — Portainer Migration
|
||||||
|
|
||||||
|
**Context:** Coolify has been deprecated and shut down. Infrastructure migration to Portainer is being handled by another agent. All deployment references updated from Coolify to Portainer in PRD and mission manifest.
|
||||||
|
|
||||||
|
**Impact on MS19:**
|
||||||
|
|
||||||
|
- Deployment target is now Portainer (was Coolify)
|
||||||
|
- No code changes needed — only infrastructure config
|
||||||
|
- Smoke testing blocked until Portainer stack is running
|
||||||
|
|
||||||
|
## Session Log
|
||||||
|
|
||||||
|
### S1 — 2026-02-25
|
||||||
|
|
||||||
|
- Read mission state (MS18 complete, all 16 tasks done)
|
||||||
|
- Explored codebase for MS19 infrastructure (chat, terminal, WebSocket, orchestrator)
|
||||||
|
- Updated PRD: Coolify → Portainer, added MS18 completion, added MS19 FRs and acceptance criteria
|
||||||
|
- Created new MISSION-MANIFEST.md for MS19+ mission
|
||||||
|
- Created TASKS.md with 12-task breakdown (~250K token estimate)
|
||||||
|
- Created this scratchpad
|
||||||
|
- Archived MS18 TASKS.md to docs/tasks/MS18-ThemeWidgets-tasks.md
|
||||||
34
docs/tasks/MS18-ThemeWidgets-tasks.md
Normal file
34
docs/tasks/MS18-ThemeWidgets-tasks.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Tasks — MS18 Theme & Widget System
|
||||||
|
|
||||||
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
|
||||||
|
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||||
|
| ----------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---- | -------------------------------- | ------------------------------------------------------ | ------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ------------------------------------------------------------------------------------ |
|
||||||
|
| TW-PLAN-001 | done | Plan MS18 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | TW-THM-001,TW-WDG-001,TW-EDT-001,TW-KBN-001 | orchestrator | 2026-02-23 | 2026-02-23 | 15K | ~12K | Planning complete, all artifacts committed |
|
||||||
|
| TW-THM-001 | done | Theme architecture — Create theme definition interface, theme registry, and 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TS files | #487 | web | feat/ms18-theme-architecture | TW-PLAN-001 | TW-THM-002,TW-THM-003 | worker | 2026-02-23 | 2026-02-23 | 30K | ~15K | PR #493 merged |
|
||||||
|
| TW-THM-002 | done | ThemeProvider upgrade — Load themes dynamically from registry, apply CSS variables, support instant theme switching without page reload | #487 | web | feat/ms18-theme-provider-upgrade | TW-THM-001 | TW-THM-003,TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~12K | PR #494 merged |
|
||||||
|
| TW-THM-003 | done | Theme selection UI — Settings page section with theme browser, live preview swatches, persist selection to UserPreference.theme via API | #487 | web | feat/ms18-theme-selection-ui | TW-THM-001,TW-THM-002 | TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~10K | PR #495 merged |
|
||||||
|
| TW-WDG-001 | done | Widget definition seeding — Seed 7 existing widgets into widget_definitions table with correct sizing constraints and configSchema | #488 | api | feat/ms18-widget-seed | TW-PLAN-001 | TW-WDG-002 | worker | 2026-02-23 | 2026-02-23 | 15K | ~8K | PR #496 merged |
|
||||||
|
| TW-WDG-002 | done | Dashboard → WidgetGrid migration — Replace hardcoded dashboard layout with WidgetGrid, load/save layout via UserLayout API, default layout on first visit | #488 | web | feat/ms18-widget-grid-migration | TW-WDG-001 | TW-WDG-003,TW-WDG-004,TW-WDG-005 | worker | 2026-02-23 | 2026-02-23 | 40K | ~20K | PR #497 merged |
|
||||||
|
| TW-WDG-003 | done | Widget picker UI — Drawer/dialog to browse available widgets from registry, preview size/description, add to dashboard | #488 | web | feat/ms18-widget-picker | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 25K | ~12K | PR #498 merged |
|
||||||
|
| TW-WDG-004 | done | Widget configuration UI — Per-widget settings dialog using configSchema, configure data source/filters/colors/title | #488 | web | feat/ms18-layout-management | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~8K | PR #499 merged (bundled with WDG-005) |
|
||||||
|
| TW-WDG-005 | done | Layout management UI — Save/rename/switch/delete layouts, reset to default. UI controls in dashboard header area | #488 | web | feat/ms18-layout-management | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 20K | ~8K | PR #499 merged (bundled with WDG-004) |
|
||||||
|
| TW-EDT-001 | done | Tiptap integration — Install @tiptap/react + extensions, build KnowledgeEditor component with toolbar (headings, bold, italic, lists, code, links, tables) | #489 | web | feat/ms18-tiptap-editor | TW-PLAN-001 | TW-EDT-002 | worker | 2026-02-23 | 2026-02-23 | 35K | ~12K | PR #500 merged |
|
||||||
|
| TW-EDT-002 | done | Markdown round-trip + File Manager integration — Import markdown to Tiptap, export to markdown + HTML. Replace textarea in knowledge create/edit | #489 | web | feat/ms18-markdown-roundtrip | TW-EDT-001 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~10K | PR #501 merged |
|
||||||
|
| TW-KBN-001 | done | Kanban filtering — Add filter bar (project, assignee, priority, search). Support project-level and user-level views. URL param persistence | #490 | web | feat/ms18-kanban-filtering | TW-PLAN-001 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~10K | PR #502 merged |
|
||||||
|
| TW-VER-001 | done | Tests — Unit tests for new components, update existing tests, fix any regressions | #491 | web | feat/ms18-verification-tests | TW-WDG-003,TW-WDG-004,TW-WDG-005,TW-EDT-002,TW-KBN-001 | TW-VER-002,TW-DOC-001 | worker | 2026-02-23 | 2026-02-23 | 25K | ~8K | PR #503 merged; 20 new tests (1195 total) |
|
||||||
|
| TW-VER-002 | done | Theme verification — Verify all 5 themes render correctly on all pages, no broken colors/contrast issues | #491 | web | — | TW-THM-003,TW-VER-001 | TW-DOC-001 | orchestrator | 2026-02-23 | 2026-02-23 | 15K | ~3K | All themes validated; gantt.css has 3 pre-existing hardcoded colors (not MS18 scope) |
|
||||||
|
| TW-DOC-001 | done | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #491 | — | feat/ms18-doc-verification | TW-VER-001,TW-VER-002 | TW-VER-003 | orchestrator | 2026-02-23 | 2026-02-23 | 10K | ~5K | PR #504 merged |
|
||||||
|
| TW-VER-003 | done | Deploy to Coolify + smoke test — Deploy, verify themes/widgets/editor/kanban all functional, auth working, no console errors | #491 | — | — | TW-DOC-001 | | orchestrator | 2026-02-23 | 2026-02-23 | 15K | ~5K | Deployed, HTTP 200, login page renders, no console errors |
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
| ------------- | ---------------------- |
|
||||||
|
| Total tasks | 16 |
|
||||||
|
| Completed | 16 (all tasks) |
|
||||||
|
| In Progress | 0 |
|
||||||
|
| Remaining | 0 |
|
||||||
|
| PRs merged | #493–#505 |
|
||||||
|
| Issues closed | #487, #488, #489, #490 |
|
||||||
|
| Milestone | MS18-ThemeWidgets |
|
||||||
@@ -71,7 +71,8 @@
|
|||||||
"request": "npm:@cypress/request@3.0.10",
|
"request": "npm:@cypress/request@3.0.10",
|
||||||
"qs": ">=6.15.0",
|
"qs": ">=6.15.0",
|
||||||
"tough-cookie": ">=4.1.3",
|
"tough-cookie": ">=4.1.3",
|
||||||
"undici": ">=6.23.0"
|
"undici": ">=6.23.0",
|
||||||
|
"rollup": ">=4.59.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
242
pnpm-lock.yaml
generated
242
pnpm-lock.yaml
generated
@@ -15,6 +15,7 @@ overrides:
|
|||||||
qs: '>=6.15.0'
|
qs: '>=6.15.0'
|
||||||
tough-cookie: '>=4.1.3'
|
tough-cookie: '>=4.1.3'
|
||||||
undici: '>=6.23.0'
|
undici: '>=6.23.0'
|
||||||
|
rollup: '>=4.59.0'
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
@@ -279,7 +280,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
unplugin-swc:
|
unplugin-swc:
|
||||||
specifier: ^1.5.2
|
specifier: ^1.5.2
|
||||||
version: 1.5.9(@swc/core@1.15.11)(rollup@4.57.0)
|
version: 1.5.9(@swc/core@1.15.11)(rollup@4.59.0)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.18
|
specifier: ^4.0.18
|
||||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
@@ -1583,6 +1584,7 @@ packages:
|
|||||||
|
|
||||||
'@mosaicstack/telemetry-client@0.1.1':
|
'@mosaicstack/telemetry-client@0.1.1':
|
||||||
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
|
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@mrleebo/prisma-ast@0.13.1':
|
'@mrleebo/prisma-ast@0.13.1':
|
||||||
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
||||||
@@ -2542,133 +2544,133 @@ packages:
|
|||||||
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
|
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
|
rollup: '>=4.59.0'
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
rollup:
|
rollup:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.57.0':
|
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||||
resolution: {integrity: sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==}
|
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@rollup/rollup-android-arm64@4.57.0':
|
'@rollup/rollup-android-arm64@4.59.0':
|
||||||
resolution: {integrity: sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==}
|
resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@rollup/rollup-darwin-arm64@4.57.0':
|
'@rollup/rollup-darwin-arm64@4.59.0':
|
||||||
resolution: {integrity: sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==}
|
resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rollup/rollup-darwin-x64@4.57.0':
|
'@rollup/rollup-darwin-x64@4.59.0':
|
||||||
resolution: {integrity: sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==}
|
resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rollup/rollup-freebsd-arm64@4.57.0':
|
'@rollup/rollup-freebsd-arm64@4.59.0':
|
||||||
resolution: {integrity: sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==}
|
resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
'@rollup/rollup-freebsd-x64@4.57.0':
|
'@rollup/rollup-freebsd-x64@4.59.0':
|
||||||
resolution: {integrity: sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==}
|
resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-gnueabihf@4.57.0':
|
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
|
||||||
resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==}
|
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.57.0':
|
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||||
resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==}
|
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.57.0':
|
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==}
|
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.57.0':
|
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==}
|
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.57.0':
|
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==}
|
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-musl@4.57.0':
|
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==}
|
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.57.0':
|
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==}
|
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-musl@4.57.0':
|
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==}
|
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.57.0':
|
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==}
|
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.57.0':
|
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==}
|
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.57.0':
|
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==}
|
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.57.0':
|
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==}
|
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.57.0':
|
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==}
|
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-openbsd-x64@4.57.0':
|
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||||
resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==}
|
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [openbsd]
|
os: [openbsd]
|
||||||
|
|
||||||
'@rollup/rollup-openharmony-arm64@4.57.0':
|
'@rollup/rollup-openharmony-arm64@4.59.0':
|
||||||
resolution: {integrity: sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==}
|
resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [openharmony]
|
os: [openharmony]
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.57.0':
|
'@rollup/rollup-win32-arm64-msvc@4.59.0':
|
||||||
resolution: {integrity: sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==}
|
resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rollup/rollup-win32-ia32-msvc@4.57.0':
|
'@rollup/rollup-win32-ia32-msvc@4.59.0':
|
||||||
resolution: {integrity: sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==}
|
resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rollup/rollup-win32-x64-gnu@4.57.0':
|
'@rollup/rollup-win32-x64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==}
|
resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rollup/rollup-win32-x64-msvc@4.57.0':
|
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||||
resolution: {integrity: sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==}
|
resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
@@ -6559,8 +6561,8 @@ packages:
|
|||||||
robust-predicates@3.0.2:
|
robust-predicates@3.0.2:
|
||||||
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
|
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
|
||||||
|
|
||||||
rollup@4.57.0:
|
rollup@4.59.0:
|
||||||
resolution: {integrity: sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==}
|
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -7937,7 +7939,7 @@ snapshots:
|
|||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
commander: 12.1.0
|
commander: 12.1.0
|
||||||
dotenv: 17.2.4
|
dotenv: 17.2.4
|
||||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
open: 10.2.0
|
open: 10.2.0
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prettier: 3.8.1
|
prettier: 3.8.1
|
||||||
@@ -9870,87 +9872,87 @@ snapshots:
|
|||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||||
|
|
||||||
'@rollup/pluginutils@5.3.0(rollup@4.57.0)':
|
'@rollup/pluginutils@5.3.0(rollup@4.59.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
estree-walker: 2.0.2
|
estree-walker: 2.0.2
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
rollup: 4.57.0
|
rollup: 4.59.0
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.57.0':
|
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-android-arm64@4.57.0':
|
'@rollup/rollup-android-arm64@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-darwin-arm64@4.57.0':
|
'@rollup/rollup-darwin-arm64@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-darwin-x64@4.57.0':
|
'@rollup/rollup-darwin-x64@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-freebsd-arm64@4.57.0':
|
'@rollup/rollup-freebsd-arm64@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-freebsd-x64@4.57.0':
|
'@rollup/rollup-freebsd-x64@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-gnueabihf@4.57.0':
|
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.57.0':
|
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.57.0':
|
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.57.0':
|
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.57.0':
|
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-musl@4.57.0':
|
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.57.0':
|
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-musl@4.57.0':
|
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.57.0':
|
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.57.0':
|
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.57.0':
|
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.57.0':
|
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.57.0':
|
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-openbsd-x64@4.57.0':
|
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-openharmony-arm64@4.57.0':
|
'@rollup/rollup-openharmony-arm64@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.57.0':
|
'@rollup/rollup-win32-arm64-msvc@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-ia32-msvc@4.57.0':
|
'@rollup/rollup-win32-ia32-msvc@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-x64-gnu@4.57.0':
|
'@rollup/rollup-win32-x64-gnu@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-x64-msvc@4.57.0':
|
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@sapphire/async-queue@1.5.5': {}
|
'@sapphire/async-queue@1.5.5': {}
|
||||||
@@ -11257,7 +11259,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
better-sqlite3: 12.6.2
|
better-sqlite3: 12.6.2
|
||||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
@@ -11282,7 +11284,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
better-sqlite3: 12.6.2
|
better-sqlite3: 12.6.2
|
||||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
@@ -12101,17 +12103,6 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@17.2.4: {}
|
dotenv@17.2.4: {}
|
||||||
|
|
||||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
|
||||||
optionalDependencies:
|
|
||||||
'@opentelemetry/api': 1.9.0
|
|
||||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
|
||||||
'@types/pg': 8.16.0
|
|
||||||
better-sqlite3: 12.6.2
|
|
||||||
kysely: 0.28.10
|
|
||||||
pg: 8.17.2
|
|
||||||
postgres: 3.4.8
|
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
|
||||||
|
|
||||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
@@ -12122,7 +12113,6 @@ snapshots:
|
|||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
postgres: 3.4.8
|
postgres: 3.4.8
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
optional: true
|
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -14178,35 +14168,35 @@ snapshots:
|
|||||||
|
|
||||||
robust-predicates@3.0.2: {}
|
robust-predicates@3.0.2: {}
|
||||||
|
|
||||||
rollup@4.57.0:
|
rollup@4.59.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@rollup/rollup-android-arm-eabi': 4.57.0
|
'@rollup/rollup-android-arm-eabi': 4.59.0
|
||||||
'@rollup/rollup-android-arm64': 4.57.0
|
'@rollup/rollup-android-arm64': 4.59.0
|
||||||
'@rollup/rollup-darwin-arm64': 4.57.0
|
'@rollup/rollup-darwin-arm64': 4.59.0
|
||||||
'@rollup/rollup-darwin-x64': 4.57.0
|
'@rollup/rollup-darwin-x64': 4.59.0
|
||||||
'@rollup/rollup-freebsd-arm64': 4.57.0
|
'@rollup/rollup-freebsd-arm64': 4.59.0
|
||||||
'@rollup/rollup-freebsd-x64': 4.57.0
|
'@rollup/rollup-freebsd-x64': 4.59.0
|
||||||
'@rollup/rollup-linux-arm-gnueabihf': 4.57.0
|
'@rollup/rollup-linux-arm-gnueabihf': 4.59.0
|
||||||
'@rollup/rollup-linux-arm-musleabihf': 4.57.0
|
'@rollup/rollup-linux-arm-musleabihf': 4.59.0
|
||||||
'@rollup/rollup-linux-arm64-gnu': 4.57.0
|
'@rollup/rollup-linux-arm64-gnu': 4.59.0
|
||||||
'@rollup/rollup-linux-arm64-musl': 4.57.0
|
'@rollup/rollup-linux-arm64-musl': 4.59.0
|
||||||
'@rollup/rollup-linux-loong64-gnu': 4.57.0
|
'@rollup/rollup-linux-loong64-gnu': 4.59.0
|
||||||
'@rollup/rollup-linux-loong64-musl': 4.57.0
|
'@rollup/rollup-linux-loong64-musl': 4.59.0
|
||||||
'@rollup/rollup-linux-ppc64-gnu': 4.57.0
|
'@rollup/rollup-linux-ppc64-gnu': 4.59.0
|
||||||
'@rollup/rollup-linux-ppc64-musl': 4.57.0
|
'@rollup/rollup-linux-ppc64-musl': 4.59.0
|
||||||
'@rollup/rollup-linux-riscv64-gnu': 4.57.0
|
'@rollup/rollup-linux-riscv64-gnu': 4.59.0
|
||||||
'@rollup/rollup-linux-riscv64-musl': 4.57.0
|
'@rollup/rollup-linux-riscv64-musl': 4.59.0
|
||||||
'@rollup/rollup-linux-s390x-gnu': 4.57.0
|
'@rollup/rollup-linux-s390x-gnu': 4.59.0
|
||||||
'@rollup/rollup-linux-x64-gnu': 4.57.0
|
'@rollup/rollup-linux-x64-gnu': 4.59.0
|
||||||
'@rollup/rollup-linux-x64-musl': 4.57.0
|
'@rollup/rollup-linux-x64-musl': 4.59.0
|
||||||
'@rollup/rollup-openbsd-x64': 4.57.0
|
'@rollup/rollup-openbsd-x64': 4.59.0
|
||||||
'@rollup/rollup-openharmony-arm64': 4.57.0
|
'@rollup/rollup-openharmony-arm64': 4.59.0
|
||||||
'@rollup/rollup-win32-arm64-msvc': 4.57.0
|
'@rollup/rollup-win32-arm64-msvc': 4.59.0
|
||||||
'@rollup/rollup-win32-ia32-msvc': 4.57.0
|
'@rollup/rollup-win32-ia32-msvc': 4.59.0
|
||||||
'@rollup/rollup-win32-x64-gnu': 4.57.0
|
'@rollup/rollup-win32-x64-gnu': 4.59.0
|
||||||
'@rollup/rollup-win32-x64-msvc': 4.57.0
|
'@rollup/rollup-win32-x64-msvc': 4.59.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
rope-sequence@1.3.4: {}
|
rope-sequence@1.3.4: {}
|
||||||
@@ -14967,9 +14957,9 @@ snapshots:
|
|||||||
|
|
||||||
unpipe@1.0.0: {}
|
unpipe@1.0.0: {}
|
||||||
|
|
||||||
unplugin-swc@1.5.9(@swc/core@1.15.11)(rollup@4.57.0):
|
unplugin-swc@1.5.9(@swc/core@1.15.11)(rollup@4.59.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rollup/pluginutils': 5.3.0(rollup@4.57.0)
|
'@rollup/pluginutils': 5.3.0(rollup@4.59.0)
|
||||||
'@swc/core': 1.15.11
|
'@swc/core': 1.15.11
|
||||||
load-tsconfig: 0.2.5
|
load-tsconfig: 0.2.5
|
||||||
unplugin: 2.3.11
|
unplugin: 2.3.11
|
||||||
@@ -15086,7 +15076,7 @@ snapshots:
|
|||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
rollup: 4.57.0
|
rollup: 4.59.0
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.7
|
'@types/node': 22.19.7
|
||||||
@@ -15102,7 +15092,7 @@ snapshots:
|
|||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
rollup: 4.57.0
|
rollup: 4.59.0
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.7
|
'@types/node': 22.19.7
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ packages:
|
|||||||
- packages/*
|
- packages/*
|
||||||
|
|
||||||
ignoredBuiltDependencies:
|
ignoredBuiltDependencies:
|
||||||
- '@nestjs/core'
|
- "@nestjs/core"
|
||||||
- '@swc/core'
|
- "@swc/core"
|
||||||
- better-sqlite3
|
- better-sqlite3
|
||||||
- esbuild
|
- esbuild
|
||||||
- sharp
|
- sharp
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@prisma/client'
|
- "@prisma/client"
|
||||||
- '@prisma/engines'
|
- "@prisma/engines"
|
||||||
- prisma
|
- prisma
|
||||||
|
- node-pty
|
||||||
|
|||||||
Reference in New Issue
Block a user