Compare commits

...

11 Commits

Author SHA1 Message Date
e41fedb3c2 feat(api): add terminal session persistence with Prisma model and CRUD service
Some checks failed
ci/woodpecker/push/api Pipeline failed
Adds database-backed TerminalSession model with ACTIVE/CLOSED status enum,
migration SQL, TerminalSessionService (create/findByWorkspace/close/findById),
DTO file with class-validator decorators, unit tests (12 tests), and module
registration. Workspace relation and indexed columns enable efficient session
listing and recovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:46:58 -06:00
5ba77d8952 fix(web): add random suffix to fallback assistant message IDs to prevent collisions
Some checks failed
ci/woodpecker/push/web Pipeline failed
Message IDs generated in the fallback path used only Date.now() which caused
ID collisions in rapid-send scenarios (multiple sends within the same millisecond).
Adding the same Math.random() suffix used by userMessage IDs ensures uniqueness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:41:43 -06:00
7de0e734b0 feat(web): implement SSE chat streaming with real-time token rendering (#516)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 02:39:43 +00:00
6290fc3d53 feat(api): add terminal WebSocket gateway with PTY session management (#515)
Some checks failed
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/orchestrator Pipeline failed
ci/woodpecker/push/api Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 02:27:29 +00:00
9f4de1682f fix(api): resolve CSRF guard ordering with global AuthGuard (#514)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 02:26:02 +00:00
374ca7ace3 docs: initialize MS19 Chat & Terminal mission planning (#513)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 01:49:40 +00:00
72c64d2eeb fix(api): add global /api prefix to resolve frontend route mismatch (#507)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 01:13:48 +00:00
5f6c520a98 fix(auth): prevent login page freeze on OAuth sign-in failure (#506)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-25 01:59:36 +00:00
9a7673bea2 docs: close out MS18 Theme & Widget System mission (#505)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 03:01:54 +00:00
91934b9933 docs: update mission artifacts for MS18 completion (#504)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 02:29:06 +00:00
7f89682946 test(web): add unit tests for MS18 components (#503)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 02:23:05 +00:00
45 changed files with 3629 additions and 551 deletions

View File

@@ -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",

View File

@@ -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;

View File

@@ -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")
}

View File

@@ -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: [

View File

@@ -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

View File

@@ -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;

View File

@@ -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) {}

View File

@@ -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", () => {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();

View 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;
}

View 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);
});
});
});

View 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 } });
}
}

View 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;
}

View 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") })
);
});
});
});

View 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}`;
}
}

View 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 {}

View 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);
});
});
});

View 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);
}
}
}
}

View File

@@ -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(

View File

@@ -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
----------------------------------------------------------------------------- */ ----------------------------------------------------------------------------- */

View File

@@ -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(),

View File

@@ -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>

View File

@@ -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={{

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View 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);
});
});

View File

@@ -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.");
}); });
}); });

View File

@@ -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,

View File

@@ -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)));
}
})();
} }

View File

@@ -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`

View File

@@ -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 |

View File

@@ -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-001003, WDG-001005, EDT-001002) | | 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)

View File

@@ -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-001005, EDT-001002 | Widget + editor complete — PRs #496#501 |
| S4 | 2026-02-24 | MS18 | KBN-001, VER-001002, DOC-001 | Kanban filtering, tests, theme verification, docs — PRs #502#504 |
## Open Questions ## Open Questions

View 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

View 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 |

View File

@@ -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
View File

@@ -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

View File

@@ -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