Compare commits
42 Commits
7581d26567
...
feat/ms19-
| Author | SHA1 | Date | |
|---|---|---|---|
| e41fedb3c2 | |||
| 5ba77d8952 | |||
| 7de0e734b0 | |||
| 6290fc3d53 | |||
| 9f4de1682f | |||
| 374ca7ace3 | |||
| 72c64d2eeb | |||
| 5f6c520a98 | |||
| 9a7673bea2 | |||
| 91934b9933 | |||
| 7f89682946 | |||
| 8b4c565f20 | |||
| d5ecc0b107 | |||
| a81c4a5edd | |||
| ff5a09c3fb | |||
| f93fa60fff | |||
| cc56f2cbe1 | |||
| f9cccd6965 | |||
| 90c3bbccdf | |||
| 79286e98c6 | |||
| cfd1def4a9 | |||
| f435d8e8c6 | |||
| 3d78b09064 | |||
| a7955b9b32 | |||
| 372cc100cc | |||
| 37cf813b88 | |||
| 3d5b50af11 | |||
| f30c2f790c | |||
| 05b1a93ccb | |||
| a78a8b88e1 | |||
| 172ed1d40f | |||
| ee2ddfc8b8 | |||
| 5a6d00a064 | |||
| ffda74ec12 | |||
| f97be2e6a3 | |||
| 97606713b5 | |||
| d0c720e6da | |||
| 64e817cfb8 | |||
| cd5c2218c8 | |||
| f643d2bc04 | |||
| 8957904ea9 | |||
| 458cac7cdd |
@@ -1,51 +1,14 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"mission_id": "mosaic-stack-go-live-mvp-20260222",
|
||||
"name": "Mosaic Stack Go-Live MVP",
|
||||
"description": "Ship Mosaic Stack MVP: operational dashboard with theming, task ingestion, one visible agent cycle, deployed and smoke-tested. Unblocks SagePHR, DYOR, Calibr, and downstream projects.",
|
||||
"mission_id": "prd-implementation-20260222",
|
||||
"name": "PRD implementation",
|
||||
"description": "",
|
||||
"project_path": "/home/jwoltje/src/mosaic-stack",
|
||||
"created_at": "2026-02-22T23:35:51Z",
|
||||
"created_at": "2026-02-23T03:20:55Z",
|
||||
"status": "active",
|
||||
"task_prefix": "MS",
|
||||
"quality_gates": "pnpm lint && pnpm typecheck && pnpm test",
|
||||
"milestone_version": "0.0.16",
|
||||
"milestones": [
|
||||
{
|
||||
"id": "phase-1",
|
||||
"name": "Dashboard Polish + Theming",
|
||||
"status": "pending",
|
||||
"branch": "dashboard-polish-theming",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-2",
|
||||
"name": "Task Ingestion Pipeline",
|
||||
"status": "pending",
|
||||
"branch": "task-ingestion-pipeline",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-3",
|
||||
"name": "Agent Cycle Visibility",
|
||||
"status": "pending",
|
||||
"branch": "agent-cycle-visibility",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-4",
|
||||
"name": "Deploy + Smoke Test",
|
||||
"status": "pending",
|
||||
"branch": "deploy-smoke-test",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
}
|
||||
],
|
||||
"task_prefix": "",
|
||||
"quality_gates": "",
|
||||
"milestone_version": "0.0.1",
|
||||
"milestones": [],
|
||||
"sessions": []
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"marked-gfm-heading-id": "^4.1.3",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"matrix-bot-sdk": "^0.8.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"ollama": "^0.6.3",
|
||||
"openai": "^6.17.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TerminalSessionStatus" AS ENUM ('ACTIVE', 'CLOSED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "terminal_sessions" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL DEFAULT 'Terminal',
|
||||
"status" "TerminalSessionStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"closed_at" TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT "terminal_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "terminal_sessions_workspace_id_idx" ON "terminal_sessions"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "terminal_sessions_workspace_id_status_idx" ON "terminal_sessions"("workspace_id", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "terminal_sessions" ADD CONSTRAINT "terminal_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -206,6 +206,11 @@ enum CredentialScope {
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
enum TerminalSessionStatus {
|
||||
ACTIVE
|
||||
CLOSED
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODELS
|
||||
// ============================================
|
||||
@@ -297,6 +302,7 @@ model Workspace {
|
||||
federationEventSubscriptions FederationEventSubscription[]
|
||||
llmUsageLogs LlmUsageLog[]
|
||||
userCredentials UserCredential[]
|
||||
terminalSessions TerminalSession[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@map("workspaces")
|
||||
@@ -1507,3 +1513,23 @@ model LlmUsageLog {
|
||||
@@index([conversationId])
|
||||
@@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")
|
||||
}
|
||||
|
||||
@@ -65,6 +65,136 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// WIDGET DEFINITIONS (global, not workspace-scoped)
|
||||
// ============================================
|
||||
const widgetDefs = [
|
||||
{
|
||||
name: "TasksWidget",
|
||||
displayName: "Tasks",
|
||||
description: "View and manage your tasks",
|
||||
component: "TasksWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 2,
|
||||
maxWidth: 4,
|
||||
maxHeight: null,
|
||||
configSchema: {},
|
||||
},
|
||||
{
|
||||
name: "CalendarWidget",
|
||||
displayName: "Calendar",
|
||||
description: "View upcoming events and schedule",
|
||||
component: "CalendarWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
maxWidth: 4,
|
||||
maxHeight: null,
|
||||
configSchema: {},
|
||||
},
|
||||
{
|
||||
name: "QuickCaptureWidget",
|
||||
displayName: "Quick Capture",
|
||||
description: "Quickly capture notes and tasks",
|
||||
component: "QuickCaptureWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 1,
|
||||
minWidth: 2,
|
||||
minHeight: 1,
|
||||
maxWidth: 4,
|
||||
maxHeight: 2,
|
||||
configSchema: {},
|
||||
},
|
||||
{
|
||||
name: "AgentStatusWidget",
|
||||
displayName: "Agent Status",
|
||||
description: "Monitor agent activity and status",
|
||||
component: "AgentStatusWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 2,
|
||||
maxWidth: 3,
|
||||
maxHeight: null,
|
||||
configSchema: {},
|
||||
},
|
||||
{
|
||||
name: "ActiveProjectsWidget",
|
||||
displayName: "Active Projects & Agent Chains",
|
||||
description: "View active projects and running agent sessions",
|
||||
component: "ActiveProjectsWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 3,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
maxWidth: 4,
|
||||
maxHeight: null,
|
||||
configSchema: {},
|
||||
},
|
||||
{
|
||||
name: "TaskProgressWidget",
|
||||
displayName: "Task Progress",
|
||||
description: "Live progress of orchestrator agent tasks",
|
||||
component: "TaskProgressWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 2,
|
||||
maxWidth: 3,
|
||||
maxHeight: null,
|
||||
configSchema: {},
|
||||
},
|
||||
{
|
||||
name: "OrchestratorEventsWidget",
|
||||
displayName: "Orchestrator Events",
|
||||
description: "Recent orchestration events with stream/Matrix visibility",
|
||||
component: "OrchestratorEventsWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 2,
|
||||
maxWidth: 4,
|
||||
maxHeight: null,
|
||||
configSchema: {},
|
||||
},
|
||||
];
|
||||
|
||||
for (const wd of widgetDefs) {
|
||||
await prisma.widgetDefinition.upsert({
|
||||
where: { name: wd.name },
|
||||
update: {
|
||||
displayName: wd.displayName,
|
||||
description: wd.description,
|
||||
component: wd.component,
|
||||
defaultWidth: wd.defaultWidth,
|
||||
defaultHeight: wd.defaultHeight,
|
||||
minWidth: wd.minWidth,
|
||||
minHeight: wd.minHeight,
|
||||
maxWidth: wd.maxWidth,
|
||||
maxHeight: wd.maxHeight,
|
||||
configSchema: wd.configSchema,
|
||||
},
|
||||
create: {
|
||||
name: wd.name,
|
||||
displayName: wd.displayName,
|
||||
description: wd.description,
|
||||
component: wd.component,
|
||||
defaultWidth: wd.defaultWidth,
|
||||
defaultHeight: wd.defaultHeight,
|
||||
minWidth: wd.minWidth,
|
||||
minHeight: wd.minHeight,
|
||||
maxWidth: wd.maxWidth,
|
||||
maxHeight: wd.maxHeight,
|
||||
configSchema: wd.configSchema,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Seeded ${widgetDefs.length} widget definitions`);
|
||||
|
||||
// Use transaction for atomic seed data reset and creation
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Delete existing seed data for idempotency (avoids duplicates on re-run)
|
||||
|
||||
@@ -40,6 +40,7 @@ import { CredentialsModule } from "./credentials/credentials.module";
|
||||
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
||||
import { SpeechModule } from "./speech/speech.module";
|
||||
import { DashboardModule } from "./dashboard/dashboard.module";
|
||||
import { TerminalModule } from "./terminal/terminal.module";
|
||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||
|
||||
@Module({
|
||||
@@ -103,6 +104,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
||||
MosaicTelemetryModule,
|
||||
SpeechModule,
|
||||
DashboardModule,
|
||||
TerminalModule,
|
||||
],
|
||||
controllers: [AppController, CsrfController],
|
||||
providers: [
|
||||
|
||||
@@ -254,6 +254,10 @@ export function createAuth(prisma: PrismaClient) {
|
||||
enabled: true,
|
||||
},
|
||||
plugins: [...getOidcPlugins()],
|
||||
logger: {
|
||||
disabled: false,
|
||||
level: "error",
|
||||
},
|
||||
session: {
|
||||
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
|
||||
|
||||
@@ -123,6 +123,14 @@ export class AuthController {
|
||||
|
||||
try {
|
||||
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) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
@@ -16,7 +16,7 @@ interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
@Controller("api/v1/csrf")
|
||||
@Controller("v1/csrf")
|
||||
export class CsrfController {
|
||||
constructor(private readonly csrfService: CsrfService) {}
|
||||
|
||||
|
||||
@@ -174,17 +174,19 @@ describe("CsrfGuard", () => {
|
||||
});
|
||||
|
||||
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 context = createContext(
|
||||
"POST",
|
||||
{ "csrf-token": token },
|
||||
{ "x-csrf-token": token },
|
||||
false
|
||||
// No userId - unauthenticated
|
||||
// No userId - AuthGuard hasn't run yet
|
||||
);
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
expect(() => guard.canActivate(context)).toThrow("CSRF validation requires authentication");
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject token from different session", () => {
|
||||
|
||||
@@ -89,30 +89,30 @@ export class CsrfGuard implements CanActivate {
|
||||
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;
|
||||
if (!userId) {
|
||||
this.logger.warn({
|
||||
event: "CSRF_NO_USER_CONTEXT",
|
||||
if (userId) {
|
||||
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");
|
||||
}
|
||||
} else {
|
||||
this.logger.debug({
|
||||
event: "CSRF_SKIP_SESSION_BINDING",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
securityEvent: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: "User context not yet available (global guard runs before AuthGuard)",
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import type { CommandMessageDetails, CommandResponse } from "./types/message.types";
|
||||
import type { FederationMessageStatus } from "@prisma/client";
|
||||
|
||||
@Controller("api/v1/federation")
|
||||
@Controller("v1/federation")
|
||||
export class CommandController {
|
||||
private readonly logger = new Logger(CommandController.name);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
IncomingEventAckDto,
|
||||
} from "./dto/event.dto";
|
||||
|
||||
@Controller("api/v1/federation")
|
||||
@Controller("v1/federation")
|
||||
export class EventController {
|
||||
private readonly logger = new Logger(EventController.name);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
ValidateFederatedTokenDto,
|
||||
} from "./dto/federated-auth.dto";
|
||||
|
||||
@Controller("api/v1/federation/auth")
|
||||
@Controller("v1/federation/auth")
|
||||
export class FederationAuthController {
|
||||
private readonly logger = new Logger(FederationAuthController.name);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "./dto/connection.dto";
|
||||
import { FederationConnectionStatus } from "@prisma/client";
|
||||
|
||||
@Controller("api/v1/federation")
|
||||
@Controller("v1/federation")
|
||||
export class FederationController {
|
||||
private readonly logger = new Logger(FederationController.name);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import type { QueryMessageDetails, QueryResponse } from "./types/message.types";
|
||||
import type { FederationMessageStatus } from "@prisma/client";
|
||||
|
||||
@Controller("api/v1/federation")
|
||||
@Controller("v1/federation")
|
||||
export class QueryController {
|
||||
private readonly logger = new Logger(QueryController.name);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { RequestMethod, ValidationPipe } from "@nestjs/common";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { AppModule } from "./app.module";
|
||||
import { getTrustedOrigins } from "./auth/auth.config";
|
||||
@@ -47,6 +47,16 @@ async function bootstrap() {
|
||||
|
||||
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
|
||||
// Origin list is shared with BetterAuth trustedOrigins via getTrustedOrigins()
|
||||
const trustedOrigins = getTrustedOrigins();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RunnerJobsService } from "./runner-jobs.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { BullMqModule } from "../bullmq/bullmq.module";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { WebSocketModule } from "../websocket/websocket.module";
|
||||
|
||||
/**
|
||||
* Runner Jobs Module
|
||||
@@ -12,7 +13,7 @@ import { AuthModule } from "../auth/auth.module";
|
||||
* for asynchronous job processing.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PrismaModule, BullMqModule, AuthModule],
|
||||
imports: [PrismaModule, BullMqModule, AuthModule, WebSocketModule],
|
||||
controllers: [RunnerJobsController],
|
||||
providers: [RunnerJobsService],
|
||||
exports: [RunnerJobsService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RunnerJobsService } from "./runner-jobs.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { BullMqService } from "../bullmq/bullmq.service";
|
||||
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||
import { RunnerJobStatus } from "@prisma/client";
|
||||
import { ConflictException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
@@ -19,6 +20,12 @@ describe("RunnerJobsService - Concurrency", () => {
|
||||
getQueue: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWebSocketGateway = {
|
||||
emitJobCreated: vi.fn(),
|
||||
emitJobStatusChanged: vi.fn(),
|
||||
emitJobProgress: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -37,6 +44,10 @@ describe("RunnerJobsService - Concurrency", () => {
|
||||
provide: BullMqService,
|
||||
useValue: mockBullMqService,
|
||||
},
|
||||
{
|
||||
provide: WebSocketGateway,
|
||||
useValue: mockWebSocketGateway,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RunnerJobsService } from "./runner-jobs.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { BullMqService } from "../bullmq/bullmq.service";
|
||||
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||
import { RunnerJobStatus } from "@prisma/client";
|
||||
import { NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
import { CreateJobDto, QueryJobsDto } from "./dto";
|
||||
@@ -32,6 +33,12 @@ describe("RunnerJobsService", () => {
|
||||
getQueue: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWebSocketGateway = {
|
||||
emitJobCreated: vi.fn(),
|
||||
emitJobStatusChanged: vi.fn(),
|
||||
emitJobProgress: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -44,6 +51,10 @@ describe("RunnerJobsService", () => {
|
||||
provide: BullMqService,
|
||||
useValue: mockBullMqService,
|
||||
},
|
||||
{
|
||||
provide: WebSocketGateway,
|
||||
useValue: mockWebSocketGateway,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Prisma, RunnerJobStatus } from "@prisma/client";
|
||||
import { Response } from "express";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { BullMqService } from "../bullmq/bullmq.service";
|
||||
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||
import { QUEUE_NAMES } from "../bullmq/queues";
|
||||
import { ConcurrentUpdateException } from "../common/exceptions/concurrent-update.exception";
|
||||
import type { CreateJobDto, QueryJobsDto } from "./dto";
|
||||
@@ -14,7 +15,8 @@ import type { CreateJobDto, QueryJobsDto } from "./dto";
|
||||
export class RunnerJobsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly bullMq: BullMqService
|
||||
private readonly bullMq: BullMqService,
|
||||
private readonly wsGateway: WebSocketGateway
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -56,6 +58,8 @@ export class RunnerJobsService {
|
||||
{ priority }
|
||||
);
|
||||
|
||||
this.wsGateway.emitJobCreated(workspaceId, job);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
@@ -194,6 +198,13 @@ export class RunnerJobsService {
|
||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after cancel`);
|
||||
}
|
||||
|
||||
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
|
||||
id,
|
||||
workspaceId,
|
||||
status: job.status,
|
||||
previousStatus: existingJob.status,
|
||||
});
|
||||
|
||||
return job;
|
||||
});
|
||||
}
|
||||
@@ -248,6 +259,8 @@ export class RunnerJobsService {
|
||||
{ priority: existingJob.priority }
|
||||
);
|
||||
|
||||
this.wsGateway.emitJobCreated(workspaceId, newJob);
|
||||
|
||||
return newJob;
|
||||
}
|
||||
|
||||
@@ -530,6 +543,13 @@ export class RunnerJobsService {
|
||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
||||
}
|
||||
|
||||
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
|
||||
id,
|
||||
workspaceId,
|
||||
status: updatedJob.status,
|
||||
previousStatus: existingJob.status,
|
||||
});
|
||||
|
||||
return updatedJob;
|
||||
});
|
||||
}
|
||||
@@ -606,6 +626,12 @@ export class RunnerJobsService {
|
||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
||||
}
|
||||
|
||||
this.wsGateway.emitJobProgress(workspaceId, id, {
|
||||
id,
|
||||
workspaceId,
|
||||
progressPercent: updatedJob.progressPercent,
|
||||
});
|
||||
|
||||
return updatedJob;
|
||||
});
|
||||
}
|
||||
|
||||
53
apps/api/src/terminal/terminal-session.dto.ts
Normal file
53
apps/api/src/terminal/terminal-session.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Terminal Session DTOs
|
||||
*
|
||||
* Data Transfer Objects for terminal session persistence endpoints.
|
||||
* Validated using class-validator decorators.
|
||||
*/
|
||||
|
||||
import { IsString, IsOptional, MaxLength, IsEnum, IsUUID } from "class-validator";
|
||||
import { TerminalSessionStatus } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* DTO for creating a new terminal session record.
|
||||
*/
|
||||
export class CreateTerminalSessionDto {
|
||||
@IsString()
|
||||
@IsUUID()
|
||||
workspaceId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for querying terminal sessions by workspace.
|
||||
*/
|
||||
export class FindTerminalSessionsByWorkspaceDto {
|
||||
@IsString()
|
||||
@IsUUID()
|
||||
workspaceId!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape for a terminal session.
|
||||
*/
|
||||
export class TerminalSessionResponseDto {
|
||||
id!: string;
|
||||
workspaceId!: string;
|
||||
name!: string;
|
||||
status!: TerminalSessionStatus;
|
||||
createdAt!: Date;
|
||||
closedAt!: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for filtering terminal sessions by status.
|
||||
*/
|
||||
export class TerminalSessionStatusFilterDto {
|
||||
@IsOptional()
|
||||
@IsEnum(TerminalSessionStatus)
|
||||
status?: TerminalSessionStatus;
|
||||
}
|
||||
229
apps/api/src/terminal/terminal-session.service.spec.ts
Normal file
229
apps/api/src/terminal/terminal-session.service.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* TerminalSessionService Tests
|
||||
*
|
||||
* Unit tests for database-backed terminal session CRUD:
|
||||
* create, findByWorkspace, close, and findById.
|
||||
* PrismaService is mocked to isolate the service logic.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { TerminalSessionStatus } from "@prisma/client";
|
||||
import type { TerminalSession } from "@prisma/client";
|
||||
import { TerminalSessionService } from "./terminal-session.service";
|
||||
|
||||
// ==========================================
|
||||
// Helpers
|
||||
// ==========================================
|
||||
|
||||
function makeSession(overrides: Partial<TerminalSession> = {}): TerminalSession {
|
||||
return {
|
||||
id: "session-uuid-1",
|
||||
workspaceId: "workspace-uuid-1",
|
||||
name: "Terminal",
|
||||
status: TerminalSessionStatus.ACTIVE,
|
||||
createdAt: new Date("2026-02-25T00:00:00Z"),
|
||||
closedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Mock PrismaService
|
||||
// ==========================================
|
||||
|
||||
function makeMockPrisma() {
|
||||
return {
|
||||
terminalSession: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Tests
|
||||
// ==========================================
|
||||
|
||||
describe("TerminalSessionService", () => {
|
||||
let service: TerminalSessionService;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let mockPrisma: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPrisma = makeMockPrisma();
|
||||
service = new TerminalSessionService(mockPrisma);
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// create
|
||||
// ==========================================
|
||||
describe("create", () => {
|
||||
it("should call prisma.terminalSession.create with workspaceId only when no name provided", async () => {
|
||||
const session = makeSession();
|
||||
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||
|
||||
const result = await service.create("workspace-uuid-1");
|
||||
|
||||
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
|
||||
data: { workspaceId: "workspace-uuid-1" },
|
||||
});
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("should include name in create data when name is provided", async () => {
|
||||
const session = makeSession({ name: "My Terminal" });
|
||||
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||
|
||||
const result = await service.create("workspace-uuid-1", "My Terminal");
|
||||
|
||||
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
|
||||
data: { workspaceId: "workspace-uuid-1", name: "My Terminal" },
|
||||
});
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("should return the created session", async () => {
|
||||
const session = makeSession();
|
||||
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||
|
||||
const result = await service.create("workspace-uuid-1");
|
||||
|
||||
expect(result.id).toBe("session-uuid-1");
|
||||
expect(result.status).toBe(TerminalSessionStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// findByWorkspace
|
||||
// ==========================================
|
||||
describe("findByWorkspace", () => {
|
||||
it("should query for ACTIVE sessions in the given workspace, ordered by createdAt desc", async () => {
|
||||
const sessions = [makeSession(), makeSession({ id: "session-uuid-2" })];
|
||||
mockPrisma.terminalSession.findMany.mockResolvedValueOnce(sessions);
|
||||
|
||||
const result = await service.findByWorkspace("workspace-uuid-1");
|
||||
|
||||
expect(mockPrisma.terminalSession.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: "workspace-uuid-1",
|
||||
status: TerminalSessionStatus.ACTIVE,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should return an empty array when no active sessions exist", async () => {
|
||||
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.findByWorkspace("workspace-uuid-empty");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not include CLOSED sessions", async () => {
|
||||
// The where clause enforces ACTIVE status — verify it is present
|
||||
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
await service.findByWorkspace("workspace-uuid-1");
|
||||
|
||||
const callArgs = mockPrisma.terminalSession.findMany.mock.calls[0][0] as {
|
||||
where: { status: TerminalSessionStatus };
|
||||
};
|
||||
expect(callArgs.where.status).toBe(TerminalSessionStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// close
|
||||
// ==========================================
|
||||
describe("close", () => {
|
||||
it("should set status to CLOSED and set closedAt when session exists", async () => {
|
||||
const existingSession = makeSession();
|
||||
const closedSession = makeSession({
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: new Date("2026-02-25T01:00:00Z"),
|
||||
});
|
||||
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
|
||||
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
|
||||
|
||||
const result = await service.close("session-uuid-1");
|
||||
|
||||
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "session-uuid-1" },
|
||||
});
|
||||
expect(mockPrisma.terminalSession.update).toHaveBeenCalledWith({
|
||||
where: { id: "session-uuid-1" },
|
||||
data: {
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(TerminalSessionStatus.CLOSED);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when session does not exist", async () => {
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(service.close("nonexistent-id")).rejects.toThrow(NotFoundException);
|
||||
expect(mockPrisma.terminalSession.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should include a non-null closedAt timestamp on close", async () => {
|
||||
const existingSession = makeSession();
|
||||
const closedSession = makeSession({
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: new Date(),
|
||||
});
|
||||
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
|
||||
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
|
||||
|
||||
const result = await service.close("session-uuid-1");
|
||||
|
||||
expect(result.closedAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// findById
|
||||
// ==========================================
|
||||
describe("findById", () => {
|
||||
it("should return the session when it exists", async () => {
|
||||
const session = makeSession();
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(session);
|
||||
|
||||
const result = await service.findById("session-uuid-1");
|
||||
|
||||
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "session-uuid-1" },
|
||||
});
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("should return null when session does not exist", async () => {
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await service.findById("no-such-id");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should find CLOSED sessions as well as ACTIVE ones", async () => {
|
||||
const closedSession = makeSession({
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: new Date(),
|
||||
});
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(closedSession);
|
||||
|
||||
const result = await service.findById("session-uuid-1");
|
||||
|
||||
expect(result?.status).toBe(TerminalSessionStatus.CLOSED);
|
||||
});
|
||||
});
|
||||
});
|
||||
96
apps/api/src/terminal/terminal-session.service.ts
Normal file
96
apps/api/src/terminal/terminal-session.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* TerminalSessionService
|
||||
*
|
||||
* Manages database persistence for terminal sessions.
|
||||
* Provides CRUD operations on the TerminalSession model,
|
||||
* enabling session tracking, recovery, and workspace-level listing.
|
||||
*
|
||||
* Session lifecycle:
|
||||
* - create: record a new terminal session with ACTIVE status
|
||||
* - findByWorkspace: return all ACTIVE sessions for a workspace
|
||||
* - close: mark a session as CLOSED, set closedAt timestamp
|
||||
* - findById: retrieve a single session by ID
|
||||
*/
|
||||
|
||||
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
|
||||
import { TerminalSessionStatus } from "@prisma/client";
|
||||
import type { TerminalSession } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class TerminalSessionService {
|
||||
private readonly logger = new Logger(TerminalSessionService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Create a new terminal session record in the database.
|
||||
*
|
||||
* @param workspaceId - The workspace this session belongs to
|
||||
* @param name - Optional display name for the session (defaults to "Terminal")
|
||||
* @returns The created TerminalSession record
|
||||
*/
|
||||
async create(workspaceId: string, name?: string): Promise<TerminalSession> {
|
||||
this.logger.log(
|
||||
`Creating terminal session for workspace ${workspaceId}${name !== undefined ? ` (name: ${name})` : ""}`
|
||||
);
|
||||
|
||||
const data: { workspaceId: string; name?: string } = { workspaceId };
|
||||
if (name !== undefined) {
|
||||
data.name = name;
|
||||
}
|
||||
|
||||
return this.prisma.terminalSession.create({ data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all ACTIVE terminal sessions for a workspace.
|
||||
*
|
||||
* @param workspaceId - The workspace to query
|
||||
* @returns Array of active TerminalSession records, ordered by creation time (newest first)
|
||||
*/
|
||||
async findByWorkspace(workspaceId: string): Promise<TerminalSession[]> {
|
||||
return this.prisma.terminalSession.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: TerminalSessionStatus.ACTIVE,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a terminal session by setting its status to CLOSED and recording closedAt.
|
||||
*
|
||||
* @param id - The session ID to close
|
||||
* @returns The updated TerminalSession record
|
||||
* @throws NotFoundException if the session does not exist
|
||||
*/
|
||||
async close(id: string): Promise<TerminalSession> {
|
||||
const existing = await this.prisma.terminalSession.findUnique({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Terminal session ${id} not found`);
|
||||
}
|
||||
|
||||
this.logger.log(`Closing terminal session ${id} (workspace: ${existing.workspaceId})`);
|
||||
|
||||
return this.prisma.terminalSession.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a terminal session by ID.
|
||||
*
|
||||
* @param id - The session ID to retrieve
|
||||
* @returns The TerminalSession record, or null if not found
|
||||
*/
|
||||
async findById(id: string): Promise<TerminalSession | null> {
|
||||
return this.prisma.terminalSession.findUnique({ where: { id } });
|
||||
}
|
||||
}
|
||||
89
apps/api/src/terminal/terminal.dto.ts
Normal file
89
apps/api/src/terminal/terminal.dto.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Terminal DTOs
|
||||
*
|
||||
* Data Transfer Objects for terminal WebSocket events.
|
||||
* Validated using class-validator decorators.
|
||||
*/
|
||||
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for creating a new terminal PTY session.
|
||||
*/
|
||||
export class CreateTerminalDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
cols?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
rows?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for sending input data to a terminal PTY session.
|
||||
*/
|
||||
export class TerminalInputDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
sessionId!: string;
|
||||
|
||||
@IsString()
|
||||
data!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for resizing a terminal PTY session.
|
||||
*/
|
||||
export class TerminalResizeDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
sessionId!: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
cols!: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
rows!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for closing a terminal PTY session.
|
||||
*/
|
||||
export class CloseTerminalDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
sessionId!: string;
|
||||
}
|
||||
501
apps/api/src/terminal/terminal.gateway.spec.ts
Normal file
501
apps/api/src/terminal/terminal.gateway.spec.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* TerminalGateway Tests
|
||||
*
|
||||
* Unit tests for WebSocket terminal gateway:
|
||||
* - Authentication on connection
|
||||
* - terminal:create event handling
|
||||
* - terminal:input event handling
|
||||
* - terminal:resize event handling
|
||||
* - terminal:close event handling
|
||||
* - disconnect cleanup
|
||||
* - Error paths
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import type { Socket } from "socket.io";
|
||||
import { TerminalGateway } from "./terminal.gateway";
|
||||
import { TerminalService } from "./terminal.service";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
// ==========================================
|
||||
// Mocks
|
||||
// ==========================================
|
||||
|
||||
// Mock node-pty globally so TerminalService doesn't fail to import
|
||||
vi.mock("node-pty", () => ({
|
||||
spawn: vi.fn(() => ({
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
pid: 1000,
|
||||
})),
|
||||
}));
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
data: {
|
||||
userId?: string;
|
||||
workspaceId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function createMockSocket(id = "test-socket-id"): AuthenticatedSocket {
|
||||
return {
|
||||
id,
|
||||
emit: vi.fn(),
|
||||
join: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
data: {},
|
||||
handshake: {
|
||||
auth: { token: "valid-token" },
|
||||
query: {},
|
||||
headers: {},
|
||||
},
|
||||
} as unknown as AuthenticatedSocket;
|
||||
}
|
||||
|
||||
function createMockAuthService() {
|
||||
return {
|
||||
verifySession: vi.fn().mockResolvedValue({
|
||||
user: { id: "user-123" },
|
||||
session: { id: "session-123" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockPrismaService() {
|
||||
return {
|
||||
workspaceMember: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMockTerminalService() {
|
||||
return {
|
||||
createSession: vi.fn().mockReturnValue({
|
||||
sessionId: "session-uuid-1",
|
||||
name: undefined,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
}),
|
||||
writeToSession: vi.fn(),
|
||||
resizeSession: vi.fn(),
|
||||
closeSession: vi.fn().mockReturnValue(true),
|
||||
closeWorkspaceSessions: vi.fn(),
|
||||
sessionBelongsToWorkspace: vi.fn().mockReturnValue(true),
|
||||
getWorkspaceSessionCount: vi.fn().mockReturnValue(0),
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Tests
|
||||
// ==========================================
|
||||
|
||||
describe("TerminalGateway", () => {
|
||||
let gateway: TerminalGateway;
|
||||
let mockAuthService: ReturnType<typeof createMockAuthService>;
|
||||
let mockPrismaService: ReturnType<typeof createMockPrismaService>;
|
||||
let mockTerminalService: ReturnType<typeof createMockTerminalService>;
|
||||
let mockClient: AuthenticatedSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuthService = createMockAuthService();
|
||||
mockPrismaService = createMockPrismaService();
|
||||
mockTerminalService = createMockTerminalService();
|
||||
mockClient = createMockSocket();
|
||||
|
||||
gateway = new TerminalGateway(
|
||||
mockAuthService as unknown as AuthService,
|
||||
mockPrismaService as unknown as PrismaService,
|
||||
mockTerminalService as unknown as TerminalService
|
||||
);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// handleConnection (authentication)
|
||||
// ==========================================
|
||||
describe("handleConnection", () => {
|
||||
it("should authenticate client and join workspace room on valid token", async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue({
|
||||
user: { id: "user-123" },
|
||||
});
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token");
|
||||
expect(mockClient.data.userId).toBe("user-123");
|
||||
expect(mockClient.data.workspaceId).toBe("workspace-456");
|
||||
expect(mockClient.join).toHaveBeenCalledWith("terminal:workspace-456");
|
||||
});
|
||||
|
||||
it("should disconnect and emit error if no token provided", async () => {
|
||||
const clientNoToken = createMockSocket("no-token");
|
||||
clientNoToken.handshake = {
|
||||
auth: {},
|
||||
query: {},
|
||||
headers: {},
|
||||
} as typeof clientNoToken.handshake;
|
||||
|
||||
await gateway.handleConnection(clientNoToken);
|
||||
|
||||
expect(clientNoToken.disconnect).toHaveBeenCalled();
|
||||
expect(clientNoToken.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("no token") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should disconnect and emit error if token is invalid", async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue(null);
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("invalid") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should disconnect and emit error if no workspace access", async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue(null);
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("workspace") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should disconnect and emit error if auth throws", async () => {
|
||||
mockAuthService.verifySession.mockRejectedValue(new Error("Auth service down"));
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.any(String) })
|
||||
);
|
||||
});
|
||||
|
||||
it("should extract token from handshake.query as fallback", async () => {
|
||||
const clientQueryToken = createMockSocket("query-token-client");
|
||||
clientQueryToken.handshake = {
|
||||
auth: {},
|
||||
query: { token: "query-token" },
|
||||
headers: {},
|
||||
} as typeof clientQueryToken.handshake;
|
||||
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
|
||||
await gateway.handleConnection(clientQueryToken);
|
||||
|
||||
expect(mockAuthService.verifySession).toHaveBeenCalledWith("query-token");
|
||||
});
|
||||
|
||||
it("should extract token from Authorization header as last fallback", async () => {
|
||||
const clientHeaderToken = createMockSocket("header-token-client");
|
||||
clientHeaderToken.handshake = {
|
||||
auth: {},
|
||||
query: {},
|
||||
headers: { authorization: "Bearer header-token" },
|
||||
} as typeof clientHeaderToken.handshake;
|
||||
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
|
||||
await gateway.handleConnection(clientHeaderToken);
|
||||
|
||||
expect(mockAuthService.verifySession).toHaveBeenCalledWith("header-token");
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// handleDisconnect
|
||||
// ==========================================
|
||||
describe("handleDisconnect", () => {
|
||||
it("should close all workspace sessions on disconnect", async () => {
|
||||
await gateway.handleConnection(mockClient);
|
||||
vi.clearAllMocks();
|
||||
|
||||
gateway.handleDisconnect(mockClient);
|
||||
|
||||
expect(mockTerminalService.closeWorkspaceSessions).toHaveBeenCalledWith("workspace-456");
|
||||
});
|
||||
|
||||
it("should not throw for unauthenticated client disconnect", () => {
|
||||
const unauthClient = createMockSocket("unauth-disconnect");
|
||||
|
||||
expect(() => gateway.handleDisconnect(unauthClient)).not.toThrow();
|
||||
expect(mockTerminalService.closeWorkspaceSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// handleCreate (terminal:create)
|
||||
// ==========================================
|
||||
describe("handleCreate", () => {
|
||||
beforeEach(async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
await gateway.handleConnection(mockClient);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should create a PTY session and emit terminal:created", async () => {
|
||||
mockTerminalService.createSession.mockReturnValue({
|
||||
sessionId: "new-session-id",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
|
||||
await gateway.handleCreate(mockClient, {});
|
||||
|
||||
expect(mockTerminalService.createSession).toHaveBeenCalled();
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:created",
|
||||
expect.objectContaining({ sessionId: "new-session-id" })
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass cols, rows, cwd, name to service", async () => {
|
||||
await gateway.handleCreate(mockClient, {
|
||||
cols: 132,
|
||||
rows: 50,
|
||||
cwd: "/home/user",
|
||||
name: "my-shell",
|
||||
});
|
||||
|
||||
expect(mockTerminalService.createSession).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ cols: 132, rows: 50, cwd: "/home/user", name: "my-shell" })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error if not authenticated", async () => {
|
||||
const unauthClient = createMockSocket("unauth");
|
||||
|
||||
await gateway.handleCreate(unauthClient, {});
|
||||
|
||||
expect(unauthClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("authenticated") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error if service throws (session limit)", async () => {
|
||||
mockTerminalService.createSession.mockImplementation(() => {
|
||||
throw new Error("Workspace has reached the maximum of 10 concurrent terminal sessions");
|
||||
});
|
||||
|
||||
await gateway.handleCreate(mockClient, {});
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("maximum") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error for invalid payload (negative cols)", async () => {
|
||||
await gateway.handleCreate(mockClient, { cols: -1 });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// handleInput (terminal:input)
|
||||
// ==========================================
|
||||
describe("handleInput", () => {
|
||||
beforeEach(async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
await gateway.handleConnection(mockClient);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should write data to the PTY session", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||
|
||||
await gateway.handleInput(mockClient, { sessionId: "sess-1", data: "ls\n" });
|
||||
|
||||
expect(mockTerminalService.writeToSession).toHaveBeenCalledWith("sess-1", "ls\n");
|
||||
});
|
||||
|
||||
it("should emit terminal:error if session does not belong to workspace", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
||||
|
||||
await gateway.handleInput(mockClient, { sessionId: "alien-sess", data: "data" });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||
);
|
||||
expect(mockTerminalService.writeToSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should emit terminal:error if not authenticated", async () => {
|
||||
const unauthClient = createMockSocket("unauth");
|
||||
|
||||
await gateway.handleInput(unauthClient, { sessionId: "sess-1", data: "x" });
|
||||
|
||||
expect(unauthClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("authenticated") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
|
||||
await gateway.handleInput(mockClient, { data: "some input" });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// handleResize (terminal:resize)
|
||||
// ==========================================
|
||||
describe("handleResize", () => {
|
||||
beforeEach(async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
await gateway.handleConnection(mockClient);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should resize the PTY session", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||
|
||||
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 120, rows: 40 });
|
||||
|
||||
expect(mockTerminalService.resizeSession).toHaveBeenCalledWith("sess-1", 120, 40);
|
||||
});
|
||||
|
||||
it("should emit terminal:error if session does not belong to workspace", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
||||
|
||||
await gateway.handleResize(mockClient, { sessionId: "alien-sess", cols: 80, rows: 24 });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error for invalid payload (cols too large)", async () => {
|
||||
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 9999, rows: 24 });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// handleClose (terminal:close)
|
||||
// ==========================================
|
||||
describe("handleClose", () => {
|
||||
beforeEach(async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
await gateway.handleConnection(mockClient);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should close an existing PTY session", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||
mockTerminalService.closeSession.mockReturnValue(true);
|
||||
|
||||
await gateway.handleClose(mockClient, { sessionId: "sess-1" });
|
||||
|
||||
expect(mockTerminalService.closeSession).toHaveBeenCalledWith("sess-1");
|
||||
});
|
||||
|
||||
it("should emit terminal:error if session does not belong to workspace", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
||||
|
||||
await gateway.handleClose(mockClient, { sessionId: "alien-sess" });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error if closeSession returns false (session gone)", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||
mockTerminalService.closeSession.mockReturnValue(false);
|
||||
|
||||
await gateway.handleClose(mockClient, { sessionId: "gone-sess" });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
|
||||
await gateway.handleClose(mockClient, {});
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
423
apps/api/src/terminal/terminal.gateway.ts
Normal file
423
apps/api/src/terminal/terminal.gateway.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* TerminalGateway
|
||||
*
|
||||
* WebSocket gateway for real-time PTY terminal sessions.
|
||||
* Uses the `/terminal` namespace to keep terminal traffic separate
|
||||
* from the main WebSocket gateway.
|
||||
*
|
||||
* Protocol:
|
||||
* 1. Client connects with auth token in handshake
|
||||
* 2. Client emits `terminal:create` to spawn a new PTY session
|
||||
* 3. Server emits `terminal:created` with { sessionId }
|
||||
* 4. Client emits `terminal:input` with { sessionId, data } to send keystrokes
|
||||
* 5. Server emits `terminal:output` with { sessionId, data } for stdout/stderr
|
||||
* 6. Client emits `terminal:resize` with { sessionId, cols, rows } on window resize
|
||||
* 7. Client emits `terminal:close` with { sessionId } to terminate the PTY
|
||||
* 8. Server emits `terminal:exit` with { sessionId, exitCode, signal } on PTY exit
|
||||
*
|
||||
* Authentication:
|
||||
* - Same pattern as websocket.gateway.ts and speech.gateway.ts
|
||||
* - Token extracted from handshake.auth.token / query.token / Authorization header
|
||||
*
|
||||
* Workspace isolation:
|
||||
* - Clients join room `terminal:{workspaceId}` on connect
|
||||
* - Sessions are scoped to workspace; cross-workspace access is denied
|
||||
*/
|
||||
|
||||
import {
|
||||
WebSocketGateway as WSGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
} from "@nestjs/websockets";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { TerminalService } from "./terminal.service";
|
||||
import {
|
||||
CreateTerminalDto,
|
||||
TerminalInputDto,
|
||||
TerminalResizeDto,
|
||||
CloseTerminalDto,
|
||||
} from "./terminal.dto";
|
||||
import { validate } from "class-validator";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
data: {
|
||||
userId?: string;
|
||||
workspaceId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Gateway
|
||||
// ==========================================
|
||||
|
||||
@WSGateway({
|
||||
namespace: "/terminal",
|
||||
cors: {
|
||||
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
private readonly logger = new Logger(TerminalGateway.name);
|
||||
private readonly CONNECTION_TIMEOUT_MS = 5000;
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly terminalService: TerminalService
|
||||
) {}
|
||||
|
||||
// ==========================================
|
||||
// Connection lifecycle
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Authenticate client on connection using handshake token.
|
||||
* Validates workspace membership and joins the workspace-scoped room.
|
||||
*/
|
||||
async handleConnection(client: Socket): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!authenticatedClient.data.userId) {
|
||||
this.logger.warn(
|
||||
`Terminal client ${authenticatedClient.id} timed out during authentication`
|
||||
);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication timed out.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
}
|
||||
}, this.CONNECTION_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const token = this.extractTokenFromHandshake(authenticatedClient);
|
||||
|
||||
if (!token) {
|
||||
this.logger.warn(`Terminal client ${authenticatedClient.id} connected without token`);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication failed: no token provided.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionData = await this.authService.verifySession(token);
|
||||
|
||||
if (!sessionData) {
|
||||
this.logger.warn(`Terminal client ${authenticatedClient.id} has invalid token`);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication failed: invalid or expired token.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = sessionData.user as { id: string };
|
||||
const userId = user.id;
|
||||
|
||||
const workspaceMembership = await this.prisma.workspaceMember.findFirst({
|
||||
where: { userId },
|
||||
select: { workspaceId: true, userId: true, role: true },
|
||||
});
|
||||
|
||||
if (!workspaceMembership) {
|
||||
this.logger.warn(`Terminal user ${userId} has no workspace access`);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication failed: no workspace access.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
|
||||
authenticatedClient.data.userId = userId;
|
||||
authenticatedClient.data.workspaceId = workspaceMembership.workspaceId;
|
||||
|
||||
// Join workspace-scoped terminal room
|
||||
const room = this.getWorkspaceRoom(workspaceMembership.workspaceId);
|
||||
await authenticatedClient.join(room);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
this.logger.log(
|
||||
`Terminal client ${authenticatedClient.id} connected (user: ${userId}, workspace: ${workspaceMembership.workspaceId})`
|
||||
);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
this.logger.error(
|
||||
`Authentication failed for terminal client ${authenticatedClient.id}:`,
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication failed: an unexpected error occurred.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all PTY sessions for this client's workspace on disconnect.
|
||||
*/
|
||||
handleDisconnect(client: Socket): void {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { workspaceId, userId } = authenticatedClient.data;
|
||||
|
||||
if (workspaceId) {
|
||||
this.terminalService.closeWorkspaceSessions(workspaceId);
|
||||
|
||||
const room = this.getWorkspaceRoom(workspaceId);
|
||||
void authenticatedClient.leave(room);
|
||||
this.logger.log(
|
||||
`Terminal client ${authenticatedClient.id} disconnected (user: ${userId ?? "unknown"}, workspace: ${workspaceId})`
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`Terminal client ${authenticatedClient.id} disconnected (unauthenticated)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Terminal events
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Spawn a new PTY session for the connected client.
|
||||
*
|
||||
* Emits `terminal:created` with { sessionId, name, cols, rows } on success.
|
||||
* Emits `terminal:error` on failure.
|
||||
*/
|
||||
@SubscribeMessage("terminal:create")
|
||||
async handleCreate(client: Socket, payload: unknown): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { userId, workspaceId } = authenticatedClient.data;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Not authenticated. Connect with a valid token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate DTO
|
||||
const dto = plainToInstance(CreateTerminalDto, payload ?? {});
|
||||
const errors = await validate(dto);
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Invalid payload: ${messages}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = this.terminalService.createSession(authenticatedClient, {
|
||||
workspaceId,
|
||||
socketId: authenticatedClient.id,
|
||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||
...(dto.cols !== undefined ? { cols: dto.cols } : {}),
|
||||
...(dto.rows !== undefined ? { rows: dto.rows } : {}),
|
||||
...(dto.cwd !== undefined ? { cwd: dto.cwd } : {}),
|
||||
});
|
||||
|
||||
authenticatedClient.emit("terminal:created", {
|
||||
sessionId: result.sessionId,
|
||||
name: result.name,
|
||||
cols: result.cols,
|
||||
rows: result.rows,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Terminal session ${result.sessionId} created for client ${authenticatedClient.id} (workspace: ${workspaceId})`
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(
|
||||
`Failed to create terminal session for client ${authenticatedClient.id}: ${message}`
|
||||
);
|
||||
authenticatedClient.emit("terminal:error", { message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write input data to an existing PTY session.
|
||||
*
|
||||
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||
*/
|
||||
@SubscribeMessage("terminal:input")
|
||||
async handleInput(client: Socket, payload: unknown): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { userId, workspaceId } = authenticatedClient.data;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Not authenticated. Connect with a valid token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dto = plainToInstance(TerminalInputDto, payload ?? {});
|
||||
const errors = await validate(dto);
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Invalid payload: ${messages}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.terminalService.writeToSession(dto.sessionId, dto.data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Failed to write to terminal session ${dto.sessionId}: ${message}`);
|
||||
authenticatedClient.emit("terminal:error", { message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an existing PTY session.
|
||||
*
|
||||
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||
*/
|
||||
@SubscribeMessage("terminal:resize")
|
||||
async handleResize(client: Socket, payload: unknown): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { userId, workspaceId } = authenticatedClient.data;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Not authenticated. Connect with a valid token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dto = plainToInstance(TerminalResizeDto, payload ?? {});
|
||||
const errors = await validate(dto);
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Invalid payload: ${messages}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.terminalService.resizeSession(dto.sessionId, dto.cols, dto.rows);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Failed to resize terminal session ${dto.sessionId}: ${message}`);
|
||||
authenticatedClient.emit("terminal:error", { message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill and close an existing PTY session.
|
||||
*
|
||||
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||
*/
|
||||
@SubscribeMessage("terminal:close")
|
||||
async handleClose(client: Socket, payload: unknown): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { userId, workspaceId } = authenticatedClient.data;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Not authenticated. Connect with a valid token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dto = plainToInstance(CloseTerminalDto, payload ?? {});
|
||||
const errors = await validate(dto);
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Invalid payload: ${messages}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const closed = this.terminalService.closeSession(dto.sessionId);
|
||||
if (!closed) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Terminal session ${dto.sessionId} not found.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Terminal session ${dto.sessionId} closed by client ${authenticatedClient.id}`);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Private helpers
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Extract authentication token from Socket.IO handshake.
|
||||
* Checks auth.token, query.token, and Authorization header (in that order).
|
||||
*/
|
||||
private extractTokenFromHandshake(client: Socket): string | undefined {
|
||||
const authToken = client.handshake.auth.token as unknown;
|
||||
if (typeof authToken === "string" && authToken.length > 0) {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
const queryToken = client.handshake.query.token as unknown;
|
||||
if (typeof queryToken === "string" && queryToken.length > 0) {
|
||||
return queryToken;
|
||||
}
|
||||
|
||||
const authHeader = client.handshake.headers.authorization as unknown;
|
||||
if (typeof authHeader === "string") {
|
||||
const parts = authHeader.split(" ");
|
||||
const [type, token] = parts;
|
||||
if (type === "Bearer" && token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the workspace-scoped room name for the terminal namespace.
|
||||
*/
|
||||
private getWorkspaceRoom(workspaceId: string): string {
|
||||
return `terminal:${workspaceId}`;
|
||||
}
|
||||
}
|
||||
31
apps/api/src/terminal/terminal.module.ts
Normal file
31
apps/api/src/terminal/terminal.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* TerminalModule
|
||||
*
|
||||
* NestJS module for WebSocket-based terminal sessions via node-pty.
|
||||
*
|
||||
* Imports:
|
||||
* - AuthModule for WebSocket authentication (verifySession)
|
||||
* - PrismaModule for workspace membership queries and session persistence
|
||||
*
|
||||
* Providers:
|
||||
* - TerminalService: manages PTY session lifecycle (in-memory)
|
||||
* - TerminalSessionService: persists session records to the database
|
||||
* - TerminalGateway: WebSocket gateway on /terminal namespace
|
||||
*
|
||||
* The module does not export providers; terminal sessions are
|
||||
* self-contained within this module.
|
||||
*/
|
||||
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TerminalGateway } from "./terminal.gateway";
|
||||
import { TerminalService } from "./terminal.service";
|
||||
import { TerminalSessionService } from "./terminal-session.service";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
providers: [TerminalGateway, TerminalService, TerminalSessionService],
|
||||
exports: [TerminalSessionService],
|
||||
})
|
||||
export class TerminalModule {}
|
||||
337
apps/api/src/terminal/terminal.service.spec.ts
Normal file
337
apps/api/src/terminal/terminal.service.spec.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* TerminalService Tests
|
||||
*
|
||||
* Unit tests for PTY session management: create, write, resize, close,
|
||||
* workspace cleanup, and access control.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import type { Socket } from "socket.io";
|
||||
import { TerminalService, MAX_SESSIONS_PER_WORKSPACE } from "./terminal.service";
|
||||
|
||||
// ==========================================
|
||||
// Mocks
|
||||
// ==========================================
|
||||
|
||||
// Mock node-pty before importing service
|
||||
const mockPtyProcess = {
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
pid: 12345,
|
||||
};
|
||||
|
||||
vi.mock("node-pty", () => ({
|
||||
spawn: vi.fn(() => mockPtyProcess),
|
||||
}));
|
||||
|
||||
function createMockSocket(id = "socket-1"): Socket {
|
||||
return {
|
||||
id,
|
||||
emit: vi.fn(),
|
||||
join: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
data: {},
|
||||
} as unknown as Socket;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Tests
|
||||
// ==========================================
|
||||
|
||||
describe("TerminalService", () => {
|
||||
let service: TerminalService;
|
||||
let mockSocket: Socket;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mock implementations
|
||||
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
|
||||
mockPtyProcess.onExit.mockImplementation(
|
||||
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
|
||||
);
|
||||
service = new TerminalService();
|
||||
mockSocket = createMockSocket();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// createSession
|
||||
// ==========================================
|
||||
describe("createSession", () => {
|
||||
it("should create a PTY session and return sessionId", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(result.sessionId).toBeDefined();
|
||||
expect(typeof result.sessionId).toBe("string");
|
||||
expect(result.cols).toBe(80);
|
||||
expect(result.rows).toBe(24);
|
||||
});
|
||||
|
||||
it("should use provided cols and rows", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
});
|
||||
|
||||
expect(result.cols).toBe(120);
|
||||
expect(result.rows).toBe(40);
|
||||
});
|
||||
|
||||
it("should return the provided session name", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
name: "my-terminal",
|
||||
});
|
||||
|
||||
expect(result.name).toBe("my-terminal");
|
||||
});
|
||||
|
||||
it("should wire PTY onData to emit terminal:output", () => {
|
||||
let dataCallback: ((data: string) => void) | undefined;
|
||||
mockPtyProcess.onData.mockImplementation((cb: (data: string) => void) => {
|
||||
dataCallback = cb;
|
||||
});
|
||||
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(dataCallback).toBeDefined();
|
||||
dataCallback!("hello world");
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:output", {
|
||||
sessionId: result.sessionId,
|
||||
data: "hello world",
|
||||
});
|
||||
});
|
||||
|
||||
it("should wire PTY onExit to emit terminal:exit and cleanup", () => {
|
||||
let exitCallback: ((e: { exitCode: number; signal?: number }) => void) | undefined;
|
||||
mockPtyProcess.onExit.mockImplementation(
|
||||
(cb: (e: { exitCode: number; signal?: number }) => void) => {
|
||||
exitCallback = cb;
|
||||
}
|
||||
);
|
||||
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(exitCallback).toBeDefined();
|
||||
exitCallback!({ exitCode: 0 });
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:exit", {
|
||||
sessionId: result.sessionId,
|
||||
exitCode: 0,
|
||||
signal: undefined,
|
||||
});
|
||||
|
||||
// Session should be cleaned up
|
||||
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false);
|
||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
||||
});
|
||||
|
||||
it("should throw when workspace session limit is reached", () => {
|
||||
const limit = MAX_SESSIONS_PER_WORKSPACE;
|
||||
|
||||
for (let i = 0; i < limit; i++) {
|
||||
service.createSession(createMockSocket(`socket-${String(i)}`), {
|
||||
workspaceId: "ws-limit",
|
||||
socketId: `socket-${String(i)}`,
|
||||
});
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
service.createSession(createMockSocket("socket-overflow"), {
|
||||
workspaceId: "ws-limit",
|
||||
socketId: "socket-overflow",
|
||||
})
|
||||
).toThrow(/maximum/i);
|
||||
});
|
||||
|
||||
it("should allow sessions in different workspaces independently", () => {
|
||||
service.createSession(mockSocket, { workspaceId: "ws-a", socketId: "s1" });
|
||||
service.createSession(createMockSocket("s2"), { workspaceId: "ws-b", socketId: "s2" });
|
||||
|
||||
expect(service.getWorkspaceSessionCount("ws-a")).toBe(1);
|
||||
expect(service.getWorkspaceSessionCount("ws-b")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// writeToSession
|
||||
// ==========================================
|
||||
describe("writeToSession", () => {
|
||||
it("should write data to PTY", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
service.writeToSession(result.sessionId, "ls -la\n");
|
||||
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n");
|
||||
});
|
||||
|
||||
it("should throw for unknown sessionId", () => {
|
||||
expect(() => service.writeToSession("nonexistent-id", "data")).toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// resizeSession
|
||||
// ==========================================
|
||||
describe("resizeSession", () => {
|
||||
it("should resize PTY dimensions", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
service.resizeSession(result.sessionId, 132, 50);
|
||||
|
||||
expect(mockPtyProcess.resize).toHaveBeenCalledWith(132, 50);
|
||||
});
|
||||
|
||||
it("should throw for unknown sessionId", () => {
|
||||
expect(() => service.resizeSession("nonexistent-id", 80, 24)).toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// closeSession
|
||||
// ==========================================
|
||||
describe("closeSession", () => {
|
||||
it("should kill PTY and return true for existing session", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
const closed = service.closeSession(result.sessionId);
|
||||
|
||||
expect(closed).toBe(true);
|
||||
expect(mockPtyProcess.kill).toHaveBeenCalled();
|
||||
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for nonexistent sessionId", () => {
|
||||
const closed = service.closeSession("does-not-exist");
|
||||
expect(closed).toBe(false);
|
||||
});
|
||||
|
||||
it("should clean up workspace tracking after close", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(1);
|
||||
service.closeSession(result.sessionId);
|
||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
||||
});
|
||||
|
||||
it("should not throw if PTY kill throws", () => {
|
||||
mockPtyProcess.kill.mockImplementationOnce(() => {
|
||||
throw new Error("PTY already dead");
|
||||
});
|
||||
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(() => service.closeSession(result.sessionId)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// closeWorkspaceSessions
|
||||
// ==========================================
|
||||
describe("closeWorkspaceSessions", () => {
|
||||
it("should kill all sessions for a workspace", () => {
|
||||
service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" });
|
||||
service.createSession(createMockSocket("s2"), { workspaceId: "ws-1", socketId: "s2" });
|
||||
|
||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(2);
|
||||
|
||||
service.closeWorkspaceSessions("ws-1");
|
||||
|
||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
||||
expect(mockPtyProcess.kill).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should not affect sessions in other workspaces", () => {
|
||||
service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" });
|
||||
service.createSession(createMockSocket("s2"), { workspaceId: "ws-2", socketId: "s2" });
|
||||
|
||||
service.closeWorkspaceSessions("ws-1");
|
||||
|
||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
||||
expect(service.getWorkspaceSessionCount("ws-2")).toBe(1);
|
||||
});
|
||||
|
||||
it("should not throw for workspaces with no sessions", () => {
|
||||
expect(() => service.closeWorkspaceSessions("ws-nonexistent")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// sessionBelongsToWorkspace
|
||||
// ==========================================
|
||||
describe("sessionBelongsToWorkspace", () => {
|
||||
it("should return true for a session belonging to the workspace", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for a session in a different workspace", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for a nonexistent sessionId", () => {
|
||||
expect(service.sessionBelongsToWorkspace("no-such-id", "ws-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// getWorkspaceSessionCount
|
||||
// ==========================================
|
||||
describe("getWorkspaceSessionCount", () => {
|
||||
it("should return 0 for workspace with no sessions", () => {
|
||||
expect(service.getWorkspaceSessionCount("empty-ws")).toBe(0);
|
||||
});
|
||||
|
||||
it("should track session count accurately", () => {
|
||||
service.createSession(mockSocket, { workspaceId: "ws-count", socketId: "s1" });
|
||||
expect(service.getWorkspaceSessionCount("ws-count")).toBe(1);
|
||||
|
||||
service.createSession(createMockSocket("s2"), { workspaceId: "ws-count", socketId: "s2" });
|
||||
expect(service.getWorkspaceSessionCount("ws-count")).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
251
apps/api/src/terminal/terminal.service.ts
Normal file
251
apps/api/src/terminal/terminal.service.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* TerminalService
|
||||
*
|
||||
* Manages PTY (pseudo-terminal) sessions for workspace users.
|
||||
* Spawns real shell processes via node-pty, streams I/O to connected sockets,
|
||||
* and enforces per-workspace session limits.
|
||||
*
|
||||
* Session lifecycle:
|
||||
* - createSession: spawn a new PTY, wire onData/onExit, return sessionId
|
||||
* - writeToSession: send input data to PTY stdin
|
||||
* - resizeSession: resize PTY dimensions (cols x rows)
|
||||
* - closeSession: kill PTY process, emit terminal:exit, cleanup
|
||||
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import * as pty from "node-pty";
|
||||
import type { Socket } from "socket.io";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
/** Maximum concurrent PTY sessions per workspace */
|
||||
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
|
||||
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
|
||||
10
|
||||
);
|
||||
|
||||
/** Default PTY dimensions */
|
||||
const DEFAULT_COLS = 80;
|
||||
const DEFAULT_ROWS = 24;
|
||||
|
||||
export interface TerminalSession {
|
||||
sessionId: string;
|
||||
workspaceId: string;
|
||||
pty: pty.IPty;
|
||||
name?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
name?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
cwd?: string;
|
||||
workspaceId: string;
|
||||
socketId: string;
|
||||
}
|
||||
|
||||
export interface SessionCreatedResult {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TerminalService {
|
||||
private readonly logger = new Logger(TerminalService.name);
|
||||
|
||||
/**
|
||||
* Map of sessionId -> TerminalSession
|
||||
*/
|
||||
private readonly sessions = new Map<string, TerminalSession>();
|
||||
|
||||
/**
|
||||
* Map of workspaceId -> Set<sessionId> for fast per-workspace lookups
|
||||
*/
|
||||
private readonly workspaceSessions = new Map<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* Create a new PTY session for the given workspace and socket.
|
||||
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
|
||||
*
|
||||
* @throws Error if workspace session limit is exceeded
|
||||
*/
|
||||
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
|
||||
const { workspaceId, name, cwd, socketId } = options;
|
||||
const cols = options.cols ?? DEFAULT_COLS;
|
||||
const rows = options.rows ?? DEFAULT_ROWS;
|
||||
|
||||
// Enforce per-workspace session limit
|
||||
const workspaceSessionIds = this.workspaceSessions.get(workspaceId) ?? new Set<string>();
|
||||
if (workspaceSessionIds.size >= MAX_SESSIONS_PER_WORKSPACE) {
|
||||
throw new Error(
|
||||
`Workspace ${workspaceId} has reached the maximum of ${String(MAX_SESSIONS_PER_WORKSPACE)} concurrent terminal sessions`
|
||||
);
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const shell = process.env.SHELL ?? "/bin/bash";
|
||||
|
||||
this.logger.log(
|
||||
`Spawning PTY session ${sessionId} for workspace ${workspaceId} (socket: ${socketId}, shell: ${shell}, ${String(cols)}x${String(rows)})`
|
||||
);
|
||||
|
||||
const ptyProcess = pty.spawn(shell, [], {
|
||||
name: "xterm-256color",
|
||||
cols,
|
||||
rows,
|
||||
cwd: cwd ?? process.cwd(),
|
||||
env: process.env as Record<string, string>,
|
||||
});
|
||||
|
||||
const session: TerminalSession = {
|
||||
sessionId,
|
||||
workspaceId,
|
||||
pty: ptyProcess,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Track by workspace
|
||||
if (!this.workspaceSessions.has(workspaceId)) {
|
||||
this.workspaceSessions.set(workspaceId, new Set());
|
||||
}
|
||||
const wsSet = this.workspaceSessions.get(workspaceId);
|
||||
if (wsSet) {
|
||||
wsSet.add(sessionId);
|
||||
}
|
||||
|
||||
// Wire PTY stdout/stderr -> terminal:output
|
||||
ptyProcess.onData((data: string) => {
|
||||
socket.emit("terminal:output", { sessionId, data });
|
||||
});
|
||||
|
||||
// Wire PTY exit -> terminal:exit, cleanup
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
this.logger.log(
|
||||
`PTY session ${sessionId} exited (exitCode: ${String(exitCode)}, signal: ${String(signal ?? "none")})`
|
||||
);
|
||||
socket.emit("terminal:exit", { sessionId, exitCode, signal });
|
||||
this.cleanupSession(sessionId, workspaceId);
|
||||
});
|
||||
|
||||
return { sessionId, ...(name !== undefined ? { name } : {}), cols, rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write input data to a PTY session's stdin.
|
||||
*
|
||||
* @throws Error if session not found
|
||||
*/
|
||||
writeToSession(sessionId: string, data: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Terminal session ${sessionId} not found`);
|
||||
}
|
||||
session.pty.write(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a PTY session's terminal dimensions.
|
||||
*
|
||||
* @throws Error if session not found
|
||||
*/
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Terminal session ${sessionId} not found`);
|
||||
}
|
||||
session.pty.resize(cols, rows);
|
||||
this.logger.debug(`Resized PTY session ${sessionId} to ${String(cols)}x${String(rows)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill and clean up a specific PTY session.
|
||||
* Returns true if the session existed, false if it was already gone.
|
||||
*/
|
||||
closeSession(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.log(`Closing PTY session ${sessionId} for workspace ${session.workspaceId}`);
|
||||
|
||||
try {
|
||||
session.pty.kill();
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Error killing PTY session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.cleanupSession(sessionId, session.workspaceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all PTY sessions for a workspace (called on client disconnect).
|
||||
*/
|
||||
closeWorkspaceSessions(workspaceId: string): void {
|
||||
const sessionIds = this.workspaceSessions.get(workspaceId);
|
||||
if (!sessionIds || sessionIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Closing ${String(sessionIds.size)} PTY session(s) for workspace ${workspaceId} (disconnect)`
|
||||
);
|
||||
|
||||
// Copy to array to avoid mutation during iteration
|
||||
const ids = Array.from(sessionIds);
|
||||
for (const sessionId of ids) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
try {
|
||||
session.pty.kill();
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Error killing PTY session ${sessionId} on disconnect: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
this.cleanupSession(sessionId, workspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of active sessions for a workspace.
|
||||
*/
|
||||
getWorkspaceSessionCount(workspaceId: string): number {
|
||||
return this.workspaceSessions.get(workspaceId)?.size ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session belongs to a given workspace.
|
||||
* Used for access control in the gateway.
|
||||
*/
|
||||
sessionBelongsToWorkspace(sessionId: string, workspaceId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
return session?.workspaceId === workspaceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal cleanup: remove session from tracking maps.
|
||||
* Does NOT kill the PTY (caller is responsible).
|
||||
*/
|
||||
private cleanupSession(sessionId: string, workspaceId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
const workspaceSessionIds = this.workspaceSessions.get(workspaceId);
|
||||
if (workspaceSessionIds) {
|
||||
workspaceSessionIds.delete(sessionId);
|
||||
if (workspaceSessionIds.size === 0) {
|
||||
this.workspaceSessions.delete(workspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -18,15 +18,27 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^9.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@mosaic/shared": "workspace:*",
|
||||
"@mosaic/ui": "workspace:*",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.20.0",
|
||||
"@tiptap/extension-link": "^3.20.0",
|
||||
"@tiptap/extension-placeholder": "^3.20.0",
|
||||
"@tiptap/extension-table": "^3.20.0",
|
||||
"@tiptap/extension-table-cell": "^3.20.0",
|
||||
"@tiptap/extension-table-header": "^3.20.0",
|
||||
"@tiptap/extension-table-row": "^3.20.0",
|
||||
"@tiptap/pm": "^3.20.0",
|
||||
"@tiptap/react": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.20.0",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@xyflow/react": "^12.5.3",
|
||||
"better-auth": "^1.4.17",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"elkjs": "^0.9.3",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.4.1",
|
||||
"next": "^16.1.6",
|
||||
@@ -34,7 +46,8 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"recharts": "^3.7.0",
|
||||
"socket.io-client": "^4.8.3"
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tiptap-markdown": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mosaic/config": "workspace:*",
|
||||
|
||||
@@ -128,12 +128,31 @@ function LoginPageContent(): ReactElement {
|
||||
setError(null);
|
||||
const callbackURL =
|
||||
typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/";
|
||||
signIn.oauth2({ providerId, callbackURL }).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);
|
||||
});
|
||||
signIn
|
||||
.oauth2({ providerId, callbackURL })
|
||||
.then((result) => {
|
||||
// BetterAuth returns Data | Error union — check for error or missing redirect URL
|
||||
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(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { Event } from "@mosaic/shared";
|
||||
import CalendarPage from "./page";
|
||||
|
||||
// Mock the Calendar component
|
||||
@@ -15,15 +16,94 @@ vi.mock("@/components/calendar/Calendar", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock MosaicSpinner
|
||||
vi.mock("@/components/ui/MosaicSpinner", () => ({
|
||||
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
|
||||
<div data-testid="mosaic-spinner">{label ?? "Loading..."}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock useWorkspaceId
|
||||
const mockUseWorkspaceId = vi.fn<() => string | null>();
|
||||
vi.mock("@/lib/hooks", () => ({
|
||||
useWorkspaceId: (): string | null => mockUseWorkspaceId(),
|
||||
}));
|
||||
|
||||
// Mock fetchEvents
|
||||
const mockFetchEvents = vi.fn<() => Promise<Event[]>>();
|
||||
vi.mock("@/lib/api/events", () => ({
|
||||
fetchEvents: (...args: unknown[]): Promise<Event[]> => mockFetchEvents(...(args as [])),
|
||||
}));
|
||||
|
||||
const fakeEvents: Event[] = [
|
||||
{
|
||||
id: "event-1",
|
||||
title: "Team standup",
|
||||
description: "Daily standup meeting",
|
||||
startTime: new Date("2026-02-20T09:00:00Z"),
|
||||
endTime: new Date("2026-02-20T09:30:00Z"),
|
||||
allDay: false,
|
||||
location: null,
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
projectId: null,
|
||||
workspaceId: "ws-1",
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "event-2",
|
||||
title: "Sprint planning",
|
||||
description: "Bi-weekly sprint planning",
|
||||
startTime: new Date("2026-02-21T14:00:00Z"),
|
||||
endTime: new Date("2026-02-21T15:00:00Z"),
|
||||
allDay: false,
|
||||
location: null,
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
projectId: null,
|
||||
workspaceId: "ws-1",
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "event-3",
|
||||
title: "All-day workshop",
|
||||
description: null,
|
||||
startTime: new Date("2026-02-22T00:00:00Z"),
|
||||
endTime: null,
|
||||
allDay: true,
|
||||
location: "Conference Room A",
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
projectId: null,
|
||||
workspaceId: "ws-1",
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
];
|
||||
|
||||
describe("CalendarPage", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
mockUseWorkspaceId.mockReturnValue("ws-1");
|
||||
mockFetchEvents.mockResolvedValue(fakeEvents);
|
||||
});
|
||||
|
||||
it("should render the page title", (): void => {
|
||||
render(<CalendarPage />);
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Calendar");
|
||||
});
|
||||
|
||||
it("should show loading state initially", (): void => {
|
||||
// Never resolve so we stay in loading state
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
mockFetchEvents.mockReturnValue(new Promise<Event[]>(() => {}));
|
||||
render(<CalendarPage />);
|
||||
expect(screen.getByTestId("calendar")).toHaveTextContent("Loading");
|
||||
expect(screen.getByTestId("mosaic-spinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the Calendar with events after loading", async (): Promise<void> => {
|
||||
@@ -43,4 +123,31 @@ describe("CalendarPage", (): void => {
|
||||
render(<CalendarPage />);
|
||||
expect(screen.getByText("View your schedule at a glance")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show empty state when no events exist", async (): Promise<void> => {
|
||||
mockFetchEvents.mockResolvedValue([]);
|
||||
render(<CalendarPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByText("No events scheduled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show error state on API failure", async (): Promise<void> => {
|
||||
mockFetchEvents.mockRejectedValue(new Error("Network error"));
|
||||
render(<CalendarPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not fetch when workspace ID is not available", async (): Promise<void> => {
|
||||
mockUseWorkspaceId.mockReturnValue(null);
|
||||
render(<CalendarPage />);
|
||||
|
||||
// Wait a tick to ensure useEffect ran
|
||||
await waitFor((): void => {
|
||||
expect(mockFetchEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,57 +3,161 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { Calendar } from "@/components/calendar/Calendar";
|
||||
import { mockEvents } from "@/lib/api/events";
|
||||
import { fetchEvents } from "@/lib/api/events";
|
||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
import type { Event } from "@mosaic/shared";
|
||||
|
||||
export default function CalendarPage(): ReactElement {
|
||||
const workspaceId = useWorkspaceId();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadEvents();
|
||||
}, []);
|
||||
|
||||
async function loadEvents(): Promise<void> {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: Replace with real API call when backend is ready
|
||||
// const data = await fetchEvents();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
setEvents(mockEvents);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "We had trouble loading your calendar. Please try again when you're ready."
|
||||
);
|
||||
} finally {
|
||||
if (!workspaceId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const wsId = workspaceId;
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
async function loadEvents(): Promise<void> {
|
||||
try {
|
||||
const data = await fetchEvents(wsId);
|
||||
if (!cancelled) {
|
||||
setEvents(data);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("[Calendar] Failed to fetch events:", err);
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "We had trouble loading your calendar. Please try again when you're ready."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadEvents();
|
||||
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
function handleRetry(): void {
|
||||
if (!workspaceId) return;
|
||||
|
||||
const wsId = workspaceId;
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
fetchEvents(wsId)
|
||||
.then((data) => {
|
||||
setEvents(data);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("[Calendar] Retry failed:", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "We had trouble loading your calendar. Please try again when you're ready."
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||
Calendar
|
||||
</h1>
|
||||
<p style={{ color: "var(--text-muted)" }} className="mt-2">
|
||||
View your schedule at a glance
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center py-16">
|
||||
<MosaicSpinner label="Loading calendar..." />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||
Calendar
|
||||
</h1>
|
||||
<p style={{ color: "var(--text-muted)" }} className="mt-2">
|
||||
View your schedule at a glance
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-lg p-6 text-center"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--danger)" }}>{error}</p>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="mt-4 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
||||
style={{
|
||||
background: "var(--accent)",
|
||||
color: "var(--surface)",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Calendar</h1>
|
||||
<p className="text-gray-600 mt-2">View your schedule at a glance</p>
|
||||
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||
Calendar
|
||||
</h1>
|
||||
<p style={{ color: "var(--text-muted)" }} className="mt-2">
|
||||
View your schedule at a glance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error !== null ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
||||
<p className="text-amber-800">{error}</p>
|
||||
<button
|
||||
onClick={() => void loadEvents()}
|
||||
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
{events.length === 0 ? (
|
||||
<div
|
||||
className="rounded-lg p-8 text-center"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<p className="text-lg" style={{ color: "var(--text-muted)" }}>
|
||||
No events scheduled
|
||||
</p>
|
||||
<p className="text-sm mt-2" style={{ color: "var(--text-muted)" }}>
|
||||
Your calendar is clear
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Calendar events={events} isLoading={isLoading} />
|
||||
<Calendar events={events} isLoading={false} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
1436
apps/web/src/app/(authenticated)/files/page.tsx
Normal file
1436
apps/web/src/app/(authenticated)/files/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
765
apps/web/src/app/(authenticated)/kanban/page.tsx
Normal file
765
apps/web/src/app/(authenticated)/kanban/page.tsx
Normal file
@@ -0,0 +1,765 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||
import type {
|
||||
DropResult,
|
||||
DroppableProvided,
|
||||
DraggableProvided,
|
||||
DraggableStateSnapshot,
|
||||
} from "@hello-pangea/dnd";
|
||||
|
||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||
import { fetchTasks, updateTask, type TaskFilters } from "@/lib/api/tasks";
|
||||
import { fetchProjects, type Project } from "@/lib/api/projects";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Column configuration
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface ColumnConfig {
|
||||
status: TaskStatus;
|
||||
label: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const COLUMNS: ColumnConfig[] = [
|
||||
{ status: TaskStatus.NOT_STARTED, label: "To Do", accent: "var(--ms-blue-400)" },
|
||||
{ status: TaskStatus.IN_PROGRESS, label: "In Progress", accent: "var(--ms-amber-400)" },
|
||||
{ status: TaskStatus.PAUSED, label: "Paused", accent: "var(--ms-purple-400)" },
|
||||
{ status: TaskStatus.COMPLETED, label: "Done", accent: "var(--ms-teal-400)" },
|
||||
{ status: TaskStatus.ARCHIVED, label: "Archived", accent: "var(--muted)" },
|
||||
];
|
||||
|
||||
const PRIORITY_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "", label: "All Priorities" },
|
||||
{ value: TaskPriority.HIGH, label: "High" },
|
||||
{ value: TaskPriority.MEDIUM, label: "Medium" },
|
||||
{ value: TaskPriority.LOW, label: "Low" },
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Filter select shared styles
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
padding: "6px 10px",
|
||||
borderRadius: "var(--r)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--surface)",
|
||||
color: "var(--text)",
|
||||
fontSize: "0.83rem",
|
||||
outline: "none",
|
||||
minWidth: 130,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
...selectStyle,
|
||||
minWidth: 180,
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Priority badge helper
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface PriorityStyle {
|
||||
label: string;
|
||||
bg: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function getPriorityStyle(priority: TaskPriority): PriorityStyle {
|
||||
switch (priority) {
|
||||
case TaskPriority.HIGH:
|
||||
return { label: "High", bg: "rgba(229,72,77,0.15)", color: "var(--danger)" };
|
||||
case TaskPriority.MEDIUM:
|
||||
return { label: "Medium", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
||||
case TaskPriority.LOW:
|
||||
return { label: "Low", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||
default:
|
||||
return { label: String(priority), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Task Card
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
provided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
columnAccent: string;
|
||||
}
|
||||
|
||||
function TaskCard({ task, provided, snapshot, columnAccent }: TaskCardProps): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const priorityStyle = getPriorityStyle(task.priority);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: `1px solid ${hovered || snapshot.isDragging ? columnAccent : "var(--border)"}`,
|
||||
borderRadius: "var(--r)",
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
cursor: "grab",
|
||||
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||
boxShadow: snapshot.isDragging ? "var(--shadow-lg)" : "none",
|
||||
...provided.draggableProps.style,
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
fontSize: "0.875rem",
|
||||
marginBottom: 6,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{task.title}
|
||||
</div>
|
||||
|
||||
{/* Priority badge */}
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "1px 8px",
|
||||
borderRadius: "var(--r-sm)",
|
||||
background: priorityStyle.bg,
|
||||
color: priorityStyle.color,
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{priorityStyle.label}
|
||||
</span>
|
||||
|
||||
{/* Description */}
|
||||
{task.description && (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.8rem",
|
||||
margin: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Kanban Column
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface KanbanColumnProps {
|
||||
config: ColumnConfig;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
function KanbanColumn({ config, tasks }: KanbanColumnProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minWidth: 280,
|
||||
maxWidth: 340,
|
||||
flex: "1 0 280px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "var(--bg-mid)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: `3px solid ${config.accent}`,
|
||||
padding: "12px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.85rem",
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minWidth: 22,
|
||||
height: 22,
|
||||
padding: "0 6px",
|
||||
borderRadius: "var(--r)",
|
||||
background: `color-mix(in srgb, ${config.accent} 15%, transparent)`,
|
||||
color: config.accent,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--mono)",
|
||||
}}
|
||||
>
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Droppable area */}
|
||||
<Droppable droppableId={config.status}>
|
||||
{(provided: DroppableProvided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
style={{
|
||||
padding: "8px 12px 12px",
|
||||
flex: 1,
|
||||
minHeight: 80,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{tasks.map((task, index) => (
|
||||
<Draggable key={task.id} draggableId={task.id} index={index}>
|
||||
{(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
|
||||
<TaskCard
|
||||
task={task}
|
||||
provided={dragProvided}
|
||||
snapshot={dragSnapshot}
|
||||
columnAccent={config.accent}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Filter Bar
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface FilterBarProps {
|
||||
projects: Project[];
|
||||
projectId: string;
|
||||
priority: string;
|
||||
search: string;
|
||||
myTasks: boolean;
|
||||
onProjectChange: (value: string) => void;
|
||||
onPriorityChange: (value: string) => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onMyTasksToggle: () => void;
|
||||
onClear: () => void;
|
||||
hasActiveFilters: boolean;
|
||||
}
|
||||
|
||||
function FilterBar({
|
||||
projects,
|
||||
projectId,
|
||||
priority,
|
||||
search,
|
||||
myTasks,
|
||||
onProjectChange,
|
||||
onPriorityChange,
|
||||
onSearchChange,
|
||||
onMyTasksToggle,
|
||||
onClear,
|
||||
hasActiveFilters,
|
||||
}: FilterBarProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
padding: "10px 14px",
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={search}
|
||||
onChange={(e): void => {
|
||||
onSearchChange(e.target.value);
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
{/* Project filter */}
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e): void => {
|
||||
onProjectChange(e.target.value);
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">All Projects</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Priority filter */}
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e): void => {
|
||||
onPriorityChange(e.target.value);
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* My Tasks toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMyTasksToggle}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "var(--r)",
|
||||
border: myTasks ? "1px solid var(--primary)" : "1px solid var(--border)",
|
||||
background: myTasks ? "var(--primary)" : "transparent",
|
||||
color: myTasks ? "#fff" : "var(--text-2)",
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.12s ease",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
My Tasks
|
||||
</button>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "var(--r)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "transparent",
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Kanban Board Page
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
export default function KanbanPage(): ReactElement {
|
||||
const workspaceId = useWorkspaceId();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Read filters from URL params
|
||||
const filterProject = searchParams.get("project") ?? "";
|
||||
const filterPriority = searchParams.get("priority") ?? "";
|
||||
const filterSearch = searchParams.get("q") ?? "";
|
||||
const filterMyTasks = searchParams.get("my") === "1";
|
||||
|
||||
const hasActiveFilters =
|
||||
filterProject !== "" || filterPriority !== "" || filterSearch !== "" || filterMyTasks;
|
||||
|
||||
/** Update a single URL param (preserving others) */
|
||||
const setParam = useCallback(
|
||||
(key: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
router.replace(`/kanban?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[searchParams, router]
|
||||
);
|
||||
|
||||
const handleProjectChange = useCallback(
|
||||
(value: string) => {
|
||||
setParam("project", value);
|
||||
},
|
||||
[setParam]
|
||||
);
|
||||
|
||||
const handlePriorityChange = useCallback(
|
||||
(value: string) => {
|
||||
setParam("priority", value);
|
||||
},
|
||||
[setParam]
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setParam("q", value);
|
||||
},
|
||||
[setParam]
|
||||
);
|
||||
|
||||
const handleMyTasksToggle = useCallback(() => {
|
||||
setParam("my", filterMyTasks ? "" : "1");
|
||||
}, [setParam, filterMyTasks]);
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
router.replace("/kanban", { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
/* --- data fetching --- */
|
||||
|
||||
const loadTasks = useCallback(async (wsId: string | null): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const filters = wsId !== null ? { workspaceId: wsId } : {};
|
||||
const data = await fetchTasks(filters);
|
||||
setTasks(data);
|
||||
} catch (err: unknown) {
|
||||
console.error("[Kanban] Failed to fetch tasks:", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Something went wrong loading tasks. You could try again when ready."
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const filters: TaskFilters = {};
|
||||
if (workspaceId) filters.workspaceId = workspaceId;
|
||||
const [taskData, projectData] = await Promise.all([
|
||||
fetchTasks(filters),
|
||||
fetchProjects(workspaceId ?? undefined),
|
||||
]);
|
||||
if (ac.signal.aborted) return;
|
||||
setTasks(taskData);
|
||||
setProjects(projectData);
|
||||
} catch (err: unknown) {
|
||||
console.error("[Kanban] Failed to fetch tasks:", err);
|
||||
if (ac.signal.aborted) return;
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Something went wrong loading tasks. You could try again when ready."
|
||||
);
|
||||
} finally {
|
||||
if (!ac.signal.aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
return (): void => {
|
||||
ac.abort();
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
/* --- apply client-side filters --- */
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
let result = tasks;
|
||||
|
||||
if (filterProject) {
|
||||
result = result.filter((t) => t.projectId === filterProject);
|
||||
}
|
||||
|
||||
if (filterPriority) {
|
||||
result = result.filter((t) => t.priority === (filterPriority as TaskPriority));
|
||||
}
|
||||
|
||||
if (filterSearch) {
|
||||
const q = filterSearch.toLowerCase();
|
||||
result = result.filter(
|
||||
(t) => t.title.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
if (filterMyTasks) {
|
||||
// "My Tasks" filters to tasks assigned to the current user.
|
||||
// Since we don't have the current userId readily available,
|
||||
// filter by assigneeId being non-null (assigned tasks).
|
||||
// A proper implementation would compare against the logged-in user's ID.
|
||||
result = result.filter((t) => t.assigneeId !== null);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [tasks, filterProject, filterPriority, filterSearch, filterMyTasks]);
|
||||
|
||||
/* --- group tasks by status --- */
|
||||
|
||||
function groupByStatus(allTasks: Task[]): Record<TaskStatus, Task[]> {
|
||||
const grouped: Record<TaskStatus, Task[]> = {
|
||||
[TaskStatus.NOT_STARTED]: [],
|
||||
[TaskStatus.IN_PROGRESS]: [],
|
||||
[TaskStatus.PAUSED]: [],
|
||||
[TaskStatus.COMPLETED]: [],
|
||||
[TaskStatus.ARCHIVED]: [],
|
||||
};
|
||||
|
||||
for (const task of allTasks) {
|
||||
grouped[task.status].push(task);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
const grouped = groupByStatus(filteredTasks);
|
||||
|
||||
/* --- drag-and-drop handler --- */
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
const { source, destination, draggableId } = result;
|
||||
|
||||
// Dropped outside a droppable area
|
||||
if (!destination) return;
|
||||
|
||||
// Dropped in same position
|
||||
if (source.droppableId === destination.droppableId && source.index === destination.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newStatus = destination.droppableId as TaskStatus;
|
||||
const taskId = draggableId;
|
||||
|
||||
// Optimistic update: move card in local state
|
||||
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
|
||||
|
||||
// Persist to API
|
||||
const wsId = workspaceId ?? undefined;
|
||||
updateTask(taskId, { status: newStatus }, wsId).catch((err: unknown) => {
|
||||
console.error("[Kanban] Failed to update task status:", err);
|
||||
// Revert on failure by re-fetching
|
||||
void loadTasks(workspaceId);
|
||||
});
|
||||
},
|
||||
[workspaceId, loadTasks]
|
||||
);
|
||||
|
||||
/* --- retry handler --- */
|
||||
|
||||
function handleRetry(): void {
|
||||
void loadTasks(workspaceId);
|
||||
}
|
||||
|
||||
/* --- render --- */
|
||||
|
||||
return (
|
||||
<main style={{ padding: "32px 24px", minHeight: "100%" }}>
|
||||
{/* Page header */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.875rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Kanban Board
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
color: "var(--muted)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
Visualize and manage task progress across stages
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<FilterBar
|
||||
projects={projects}
|
||||
projectId={filterProject}
|
||||
priority={filterPriority}
|
||||
search={filterSearch}
|
||||
myTasks={filterMyTasks}
|
||||
onProjectChange={handleProjectChange}
|
||||
onPriorityChange={handlePriorityChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
onMyTasksToggle={handleMyTasksToggle}
|
||||
onClear={handleClearFilters}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
/>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<MosaicSpinner label="Loading tasks..." />
|
||||
</div>
|
||||
) : error !== null ? (
|
||||
/* Error state */
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: 32,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--danger)", margin: "0 0 16px" }}>{error}</p>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--danger)",
|
||||
border: "none",
|
||||
borderRadius: "var(--r)",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : filteredTasks.length === 0 && tasks.length > 0 ? (
|
||||
/* No results (filtered) */
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: 48,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
|
||||
No tasks match your filters.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: "6px 14px",
|
||||
borderRadius: "var(--r)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "transparent",
|
||||
color: "var(--text-2)",
|
||||
fontSize: "0.83rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: 48,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
|
||||
No tasks yet. Create some tasks to see them here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Board */
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 16,
|
||||
overflowX: "auto",
|
||||
paddingBottom: 16,
|
||||
minHeight: 400,
|
||||
}}
|
||||
>
|
||||
{COLUMNS.map((col) => (
|
||||
<KanbanColumn key={col.status} config={col} tasks={grouped[col.status]} />
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -2,23 +2,25 @@
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared";
|
||||
import type { EntryStatus } from "@mosaic/shared";
|
||||
import { EntryList } from "@/components/knowledge/EntryList";
|
||||
import { EntryFilters } from "@/components/knowledge/EntryFilters";
|
||||
import { ImportExportActions } from "@/components/knowledge";
|
||||
import { mockEntries, mockTags } from "@/lib/api/knowledge";
|
||||
import { fetchEntries, fetchTags } from "@/lib/api/knowledge";
|
||||
import type { EntriesResponse } from "@/lib/api/knowledge";
|
||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export default function KnowledgePage(): ReactElement {
|
||||
// TODO: Replace with real API call when backend is ready
|
||||
// const { data: entries, isLoading } = useQuery({
|
||||
// queryKey: ["knowledge-entries"],
|
||||
// queryFn: fetchEntries,
|
||||
// });
|
||||
|
||||
const [isLoading] = useState(false);
|
||||
// Data state
|
||||
const [entries, setEntries] = useState<KnowledgeEntryWithTags[]>([]);
|
||||
const [tags, setTags] = useState<KnowledgeTag[]>([]);
|
||||
const [totalEntries, setTotalEntries] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filter and sort state
|
||||
const [selectedStatus, setSelectedStatus] = useState<EntryStatus | "all">("all");
|
||||
@@ -31,60 +33,65 @@ export default function KnowledgePage(): ReactElement {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
// Client-side filtering and sorting
|
||||
const filteredAndSortedEntries = useMemo(() => {
|
||||
let filtered = [...mockEntries];
|
||||
// Load tags on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
// Filter by status
|
||||
if (selectedStatus !== "all") {
|
||||
filtered = filtered.filter((entry) => entry.status === selectedStatus);
|
||||
}
|
||||
fetchTags()
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setTags(result);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("Failed to load tags:", err);
|
||||
});
|
||||
|
||||
// Filter by tag
|
||||
if (selectedTag !== "all") {
|
||||
filtered = filtered.filter((entry) =>
|
||||
entry.tags.some((tag: { slug: string }) => tag.slug === selectedTag)
|
||||
);
|
||||
}
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(entry) =>
|
||||
entry.title.toLowerCase().includes(query) ||
|
||||
(entry.summary?.toLowerCase().includes(query) ?? false) ||
|
||||
entry.tags.some((tag: { name: string }): boolean =>
|
||||
tag.name.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
}
|
||||
// Load entries when filters/sort/page change
|
||||
const loadEntries = useCallback(async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Sort entries
|
||||
filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
try {
|
||||
const filters: Record<string, unknown> = {
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
};
|
||||
|
||||
if (sortBy === "title") {
|
||||
comparison = a.title.localeCompare(b.title);
|
||||
} else if (sortBy === "createdAt") {
|
||||
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
} else {
|
||||
// updatedAt
|
||||
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
if (selectedStatus !== "all") {
|
||||
filters.status = selectedStatus;
|
||||
}
|
||||
if (selectedTag !== "all") {
|
||||
filters.tag = selectedTag;
|
||||
}
|
||||
if (searchQuery.trim()) {
|
||||
filters.search = searchQuery.trim();
|
||||
}
|
||||
|
||||
return sortOrder === "asc" ? comparison : -comparison;
|
||||
});
|
||||
const response: EntriesResponse = await fetchEntries(
|
||||
filters as Parameters<typeof fetchEntries>[0]
|
||||
);
|
||||
setEntries(response.data);
|
||||
setTotalEntries(response.meta?.total ?? response.data.length);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load entries");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentPage, itemsPerPage, sortBy, sortOrder, selectedStatus, selectedTag, searchQuery]);
|
||||
|
||||
return filtered;
|
||||
}, [selectedStatus, selectedTag, searchQuery, sortBy, sortOrder]);
|
||||
useEffect(() => {
|
||||
void loadEntries();
|
||||
}, [loadEntries]);
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(filteredAndSortedEntries.length / itemsPerPage);
|
||||
const paginatedEntries = filteredAndSortedEntries.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
const totalPages = Math.max(1, Math.ceil(totalEntries / itemsPerPage));
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
const handleFilterChange = (callback: () => void): void => {
|
||||
@@ -101,6 +108,16 @@ export default function KnowledgePage(): ReactElement {
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
if (isLoading && entries.length === 0) {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<MosaicSpinner size={48} label="Loading knowledge base..." />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
||||
{/* Header */}
|
||||
@@ -125,14 +142,37 @@ export default function KnowledgePage(): ReactElement {
|
||||
<div className="flex justify-end">
|
||||
<ImportExportActions
|
||||
onImportComplete={() => {
|
||||
// TODO: Refresh the entry list when real API is connected
|
||||
// For now, this would trigger a refetch of the entries
|
||||
window.location.reload();
|
||||
void loadEntries();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div
|
||||
className="mb-6 p-4 rounded-lg border"
|
||||
style={{
|
||||
borderColor: "var(--danger)",
|
||||
background: "rgba(229,72,77,0.08)",
|
||||
}}
|
||||
>
|
||||
<p className="text-sm" style={{ color: "var(--danger)" }}>
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void loadEntries();
|
||||
}}
|
||||
className="mt-2 text-sm font-medium underline"
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<EntryFilters
|
||||
selectedStatus={selectedStatus}
|
||||
@@ -140,7 +180,7 @@ export default function KnowledgePage(): ReactElement {
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
tags={mockTags}
|
||||
tags={tags}
|
||||
onStatusChange={(status) => {
|
||||
handleFilterChange(() => {
|
||||
setSelectedStatus(status);
|
||||
@@ -161,7 +201,7 @@ export default function KnowledgePage(): ReactElement {
|
||||
|
||||
{/* Entry list */}
|
||||
<EntryList
|
||||
entries={paginatedEntries}
|
||||
entries={entries}
|
||||
isLoading={isLoading}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
|
||||
851
apps/web/src/app/(authenticated)/logs/page.tsx
Normal file
851
apps/web/src/app/(authenticated)/logs/page.tsx
Normal file
@@ -0,0 +1,851 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||
import { fetchRunnerJobs, fetchJobSteps, RunnerJobStatus } from "@/lib/api/runner-jobs";
|
||||
import type { RunnerJob, JobStep } from "@/lib/api/runner-jobs";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
type StatusFilter = "all" | "running" | "completed" | "failed" | "queued";
|
||||
type DateRange = "24h" | "7d" | "30d" | "all";
|
||||
|
||||
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "all", label: "All statuses" },
|
||||
{ value: "running", label: "Running" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "queued", label: "Queued" },
|
||||
];
|
||||
|
||||
const DATE_RANGES: { value: DateRange; label: string }[] = [
|
||||
{ value: "24h", label: "Last 24h" },
|
||||
{ value: "7d", label: "7d" },
|
||||
{ value: "30d", label: "30d" },
|
||||
{ value: "all", label: "All" },
|
||||
];
|
||||
|
||||
const STATUS_FILTER_TO_ENUM: Record<StatusFilter, RunnerJobStatus[] | undefined> = {
|
||||
all: undefined,
|
||||
running: [RunnerJobStatus.RUNNING],
|
||||
completed: [RunnerJobStatus.COMPLETED],
|
||||
failed: [RunnerJobStatus.FAILED],
|
||||
queued: [RunnerJobStatus.QUEUED, RunnerJobStatus.PENDING],
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "RUNNING":
|
||||
return "var(--ms-amber-400)";
|
||||
case "COMPLETED":
|
||||
return "var(--ms-teal-400)";
|
||||
case "FAILED":
|
||||
case "CANCELLED":
|
||||
return "var(--danger)";
|
||||
case "QUEUED":
|
||||
case "PENDING":
|
||||
return "var(--ms-blue-400)";
|
||||
default:
|
||||
return "var(--muted)";
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string | null): string {
|
||||
if (!dateStr) return "\u2014";
|
||||
const date = new Date(dateStr);
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1_000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffSec < 60) return "just now";
|
||||
if (diffMin < 60) return `${String(diffMin)}m ago`;
|
||||
if (diffHr < 24) return `${String(diffHr)}h ago`;
|
||||
if (diffDay < 30) return `${String(diffDay)}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: string | null, completedAt: string | null): string {
|
||||
if (!startedAt) return "\u2014";
|
||||
const start = new Date(startedAt).getTime();
|
||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||
const ms = end - start;
|
||||
if (ms < 1_000) return `${String(ms)}ms`;
|
||||
const sec = Math.floor(ms / 1_000);
|
||||
if (sec < 60) return `${String(sec)}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
const remainSec = sec % 60;
|
||||
return `${String(min)}m ${String(remainSec)}s`;
|
||||
}
|
||||
|
||||
function formatStepDuration(durationMs: number | null): string {
|
||||
if (durationMs === null) return "\u2014";
|
||||
if (durationMs < 1_000) return `${String(durationMs)}ms`;
|
||||
const sec = Math.floor(durationMs / 1_000);
|
||||
if (sec < 60) return `${String(sec)}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
const remainSec = sec % 60;
|
||||
return `${String(min)}m ${String(remainSec)}s`;
|
||||
}
|
||||
|
||||
function isWithinDateRange(dateStr: string, range: DateRange): boolean {
|
||||
if (range === "all") return true;
|
||||
const date = new Date(dateStr);
|
||||
const now = Date.now();
|
||||
const hours = range === "24h" ? 24 : range === "7d" ? 168 : 720;
|
||||
return now - date.getTime() < hours * 60 * 60 * 1_000;
|
||||
}
|
||||
|
||||
// ─── Status Badge ─────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: string }): ReactElement {
|
||||
const color = getStatusColor(status);
|
||||
const isRunning = status === "RUNNING";
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "2px 10px",
|
||||
borderRadius: 9999,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
color,
|
||||
background: `color-mix(in srgb, ${color} 15%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{isRunning && (
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
animation: "pulse 1.5s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{status.toLowerCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page Component ──────────────────────────────────────────────
|
||||
|
||||
export default function LogsPage(): ReactElement {
|
||||
const workspaceId = useWorkspaceId();
|
||||
|
||||
// Data state
|
||||
const [jobs, setJobs] = useState<RunnerJob[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Expanded job and steps
|
||||
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
||||
const [jobStepsMap, setJobStepsMap] = useState<Record<string, JobStep[]>>({});
|
||||
const [stepsLoading, setStepsLoading] = useState<Set<string>>(new Set());
|
||||
|
||||
// Filters
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [dateRange, setDateRange] = useState<DateRange>("7d");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Auto-refresh
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Hover state
|
||||
const [hoveredRowId, setHoveredRowId] = useState<string | null>(null);
|
||||
|
||||
// ─── Data Loading ─────────────────────────────────────────────────
|
||||
|
||||
const loadJobs = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const statusEnums = STATUS_FILTER_TO_ENUM[statusFilter];
|
||||
const filters: Parameters<typeof fetchRunnerJobs>[0] = {};
|
||||
if (workspaceId) {
|
||||
filters.workspaceId = workspaceId;
|
||||
}
|
||||
if (statusEnums) {
|
||||
filters.status = statusEnums;
|
||||
}
|
||||
|
||||
const data = await fetchRunnerJobs(filters);
|
||||
setJobs(data);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error("[Logs] Failed to fetch runner jobs:", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "We had trouble loading jobs. Please try again when you're ready."
|
||||
);
|
||||
}
|
||||
}, [workspaceId, statusFilter]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
||||
loadJobs()
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadJobs]);
|
||||
|
||||
// Auto-refresh polling
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
void loadJobs();
|
||||
}, POLL_INTERVAL_MS);
|
||||
} else if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, loadJobs]);
|
||||
|
||||
// ─── Steps Loading ────────────────────────────────────────────────
|
||||
|
||||
const toggleExpand = useCallback(
|
||||
(jobId: string) => {
|
||||
if (expandedJobId === jobId) {
|
||||
setExpandedJobId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedJobId(jobId);
|
||||
|
||||
// Load steps if not already loaded
|
||||
if (!jobStepsMap[jobId] && !stepsLoading.has(jobId)) {
|
||||
setStepsLoading((prev) => new Set(prev).add(jobId));
|
||||
|
||||
fetchJobSteps(jobId, workspaceId ?? undefined)
|
||||
.then((steps) => {
|
||||
setJobStepsMap((prev) => ({ ...prev, [jobId]: steps }));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("[Logs] Failed to fetch steps for job:", jobId, err);
|
||||
setJobStepsMap((prev) => ({ ...prev, [jobId]: [] }));
|
||||
})
|
||||
.finally(() => {
|
||||
setStepsLoading((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(jobId);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
[expandedJobId, jobStepsMap, stepsLoading, workspaceId]
|
||||
);
|
||||
|
||||
// ─── Filtering ────────────────────────────────────────────────────
|
||||
|
||||
const filteredJobs = jobs.filter((job) => {
|
||||
// Date range filter
|
||||
if (!isWithinDateRange(job.createdAt, dateRange)) return false;
|
||||
|
||||
// Search filter
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchesType = job.type.toLowerCase().includes(q);
|
||||
const matchesId = job.id.toLowerCase().includes(q);
|
||||
if (!matchesType && !matchesId) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// ─── Manual Refresh ───────────────────────────────────────────────
|
||||
|
||||
const handleManualRefresh = (): void => {
|
||||
setIsLoading(true);
|
||||
void loadJobs().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRetry = (): void => {
|
||||
setError(null);
|
||||
handleManualRefresh();
|
||||
};
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Pulse animation for running status */}
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
@keyframes auto-refresh-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* ─── Header ─────────────────────────────────────────────── */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 16,
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||
Logs & Telemetry
|
||||
</h1>
|
||||
<p className="mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Runner job history and step-level detail
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* Auto-refresh toggle */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setAutoRefresh((prev) => !prev);
|
||||
}}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 14px",
|
||||
borderRadius: 8,
|
||||
fontSize: "0.82rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
border: `1px solid ${autoRefresh ? "var(--ms-teal-400)" : "var(--border)"}`,
|
||||
background: autoRefresh
|
||||
? "color-mix(in srgb, var(--ms-teal-400) 12%, transparent)"
|
||||
: "var(--surface)",
|
||||
color: autoRefresh ? "var(--ms-teal-400)" : "var(--text-muted)",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
{autoRefresh && (
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "var(--ms-teal-400)",
|
||||
animation: "pulse 1.5s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
Auto-refresh {autoRefresh ? "on" : "off"}
|
||||
</button>
|
||||
|
||||
{/* Manual refresh */}
|
||||
<button
|
||||
onClick={handleManualRefresh}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
borderRadius: 8,
|
||||
fontSize: "0.82rem",
|
||||
fontWeight: 500,
|
||||
cursor: isLoading ? "not-allowed" : "pointer",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--surface)",
|
||||
color: "var(--text-muted)",
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Filter Bar ─────────────────────────────────────────── */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{/* Status filter */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value as StatusFilter);
|
||||
}}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
fontSize: "0.82rem",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--surface)",
|
||||
color: "var(--text)",
|
||||
cursor: "pointer",
|
||||
minWidth: 140,
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Date range tabs */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{DATE_RANGES.map((range) => (
|
||||
<button
|
||||
key={range.value}
|
||||
onClick={() => {
|
||||
setDateRange(range.value);
|
||||
}}
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
fontSize: "0.82rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
borderRight: "1px solid var(--border)",
|
||||
background: dateRange === range.value ? "var(--primary)" : "var(--surface)",
|
||||
color: dateRange === range.value ? "#fff" : "var(--text-muted)",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by job type..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
}}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
fontSize: "0.82rem",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--surface)",
|
||||
color: "var(--text)",
|
||||
minWidth: 200,
|
||||
flex: "1 1 200px",
|
||||
maxWidth: 320,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ─── Content ────────────────────────────────────────────── */}
|
||||
{isLoading && jobs.length === 0 ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<MosaicSpinner label="Loading jobs..." />
|
||||
</div>
|
||||
) : error !== null ? (
|
||||
<div
|
||||
className="rounded-lg p-6 text-center"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--danger)" }}>{error}</p>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="mt-4 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||
style={{ background: "var(--danger)", cursor: "pointer", border: "none" }}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : filteredJobs.length === 0 ? (
|
||||
<div
|
||||
className="rounded-lg p-8 text-center"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--text-muted)" }}>No jobs found</p>
|
||||
</div>
|
||||
) : (
|
||||
/* ─── Job Table ──────────────────────────────────────────── */
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid var(--border)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
background: "var(--bg-mid)",
|
||||
}}
|
||||
>
|
||||
{["Job Type", "Status", "Started", "Duration", "Steps"].map((header) => (
|
||||
<th
|
||||
key={header}
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
textAlign: "left",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--muted)",
|
||||
fontFamily: "var(--mono)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredJobs.map((job) => {
|
||||
const isExpanded = expandedJobId === job.id;
|
||||
const isHovered = hoveredRowId === job.id;
|
||||
const steps = jobStepsMap[job.id];
|
||||
const isStepsLoading = stepsLoading.has(job.id);
|
||||
|
||||
return (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
isExpanded={isExpanded}
|
||||
isHovered={isHovered}
|
||||
steps={steps}
|
||||
isStepsLoading={isStepsLoading}
|
||||
onToggle={() => {
|
||||
toggleExpand(job.id);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHoveredRowId(job.id);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredRowId(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Job Row Component ────────────────────────────────────────────────
|
||||
|
||||
function JobRow({
|
||||
job,
|
||||
isExpanded,
|
||||
isHovered,
|
||||
steps,
|
||||
isStepsLoading,
|
||||
onToggle,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: {
|
||||
job: RunnerJob;
|
||||
isExpanded: boolean;
|
||||
isHovered: boolean;
|
||||
steps: JobStep[] | undefined;
|
||||
isStepsLoading: boolean;
|
||||
onToggle: () => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={onToggle}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={{
|
||||
background: isExpanded
|
||||
? "var(--surface-2)"
|
||||
: isHovered
|
||||
? "var(--surface-2)"
|
||||
: "var(--surface)",
|
||||
cursor: "pointer",
|
||||
borderBottom: isExpanded ? "none" : "1px solid var(--border)",
|
||||
transition: "background 100ms ease",
|
||||
}}
|
||||
>
|
||||
<td
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 16,
|
||||
textAlign: "center",
|
||||
fontSize: "0.7rem",
|
||||
color: "var(--muted)",
|
||||
transition: "transform 150ms ease",
|
||||
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
{job.type}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: "12px 16px" }}>
|
||||
<StatusBadge status={job.status} />
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
fontSize: "0.82rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--text-muted)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{formatRelativeTime(job.startedAt ?? job.createdAt)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
fontSize: "0.82rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--text-muted)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{formatDuration(job.startedAt, job.completedAt)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
fontSize: "0.82rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{steps ? String(steps.length) : "\u2014"}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded Steps Section */}
|
||||
{isExpanded && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
style={{
|
||||
padding: 0,
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-mid)",
|
||||
padding: "12px 16px 12px 48px",
|
||||
}}
|
||||
>
|
||||
{isStepsLoading ? (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
|
||||
<MosaicSpinner size={24} label="Loading steps..." />
|
||||
</div>
|
||||
) : !steps || steps.length === 0 ? (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.82rem",
|
||||
color: "var(--text-muted)",
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
No steps recorded for this job
|
||||
</p>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{["#", "Name", "Phase", "Status", "Duration"].map((header) => (
|
||||
<th
|
||||
key={header}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
textAlign: "left",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--muted)",
|
||||
fontFamily: "var(--mono)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{steps
|
||||
.sort((a, b) => a.ordinal - b.ordinal)
|
||||
.map((step) => (
|
||||
<StepRow key={step.id} step={step} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Job error message if failed */}
|
||||
{job.error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.78rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--danger)",
|
||||
background: "color-mix(in srgb, var(--danger) 8%, transparent)",
|
||||
border: "1px solid color-mix(in srgb, var(--danger) 20%, transparent)",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{job.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step Row Component ───────────────────────────────────────────────
|
||||
|
||||
function StepRow({ step }: { step: JobStep }): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<tr
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
background: hovered ? "color-mix(in srgb, var(--surface) 50%, transparent)" : "transparent",
|
||||
borderBottom: "1px solid color-mix(in srgb, var(--border) 50%, transparent)",
|
||||
transition: "background 100ms ease",
|
||||
}}
|
||||
>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
fontSize: "0.78rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{String(step.ordinal)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{step.name}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
fontSize: "0.75rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--text-muted)",
|
||||
textTransform: "lowercase",
|
||||
}}
|
||||
>
|
||||
{step.phase}
|
||||
</td>
|
||||
<td style={{ padding: "6px 12px" }}>
|
||||
<StatusBadge status={step.status} />
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
fontSize: "0.78rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--text-muted)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{formatStepDuration(step.durationMs)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
160
apps/web/src/app/(authenticated)/not-found.tsx
Normal file
160
apps/web/src/app/(authenticated)/not-found.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { ReactElement } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function AuthenticatedNotFound(): ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "24px",
|
||||
padding: "48px 40px",
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-xl)",
|
||||
boxShadow: "var(--shadow-md)",
|
||||
textAlign: "center",
|
||||
maxWidth: "420px",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Compass icon in blue-tinted icon well */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: "var(--r-lg)",
|
||||
background: "rgba(47, 128, 255, 0.1)",
|
||||
color: "var(--ms-blue-400)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon
|
||||
points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 404 badge pill */}
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 12px",
|
||||
borderRadius: "9999px",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--mono)",
|
||||
background: "rgba(47, 128, 255, 0.15)",
|
||||
color: "var(--ms-blue-400)",
|
||||
}}
|
||||
>
|
||||
404
|
||||
</span>
|
||||
|
||||
{/* Heading + description */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
margin: 0,
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
Page not found
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--muted)",
|
||||
margin: 0,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
This page doesn't exist or you may not have permission to view it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
marginTop: "8px",
|
||||
}}
|
||||
>
|
||||
{/* Primary: Dashboard */}
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "9px 20px",
|
||||
background: "var(--ms-blue-500)",
|
||||
color: "#ffffff",
|
||||
borderRadius: "var(--r)",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
transition: "opacity 0.15s ease",
|
||||
}}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
{/* Ghost: Settings */}
|
||||
<Link
|
||||
href="/settings"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "9px 20px",
|
||||
background: "transparent",
|
||||
color: "var(--text-2)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r)",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import DashboardPage from "./page";
|
||||
import { fetchDashboardSummary } from "@/lib/api/dashboard";
|
||||
import * as layoutsApi from "@/lib/api/layouts";
|
||||
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
|
||||
|
||||
// Mock Phase 3 dashboard widgets
|
||||
vi.mock("@/components/dashboard/DashboardMetrics", () => ({
|
||||
DashboardMetrics: (): React.JSX.Element => (
|
||||
<div data-testid="dashboard-metrics">Dashboard Metrics</div>
|
||||
// ResizeObserver is not available in jsdom
|
||||
beforeAll((): void => {
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
// Mock WidgetGrid to avoid react-grid-layout dependency in tests
|
||||
vi.mock("@/components/widgets/WidgetGrid", () => ({
|
||||
WidgetGrid: ({
|
||||
layout,
|
||||
isEditing,
|
||||
}: {
|
||||
layout: WidgetPlacement[];
|
||||
isEditing?: boolean;
|
||||
}): React.JSX.Element => (
|
||||
<div data-testid="widget-grid" data-editing={isEditing}>
|
||||
{layout.map((item) => (
|
||||
<div key={item.i} data-testid={`widget-${item.i}`}>
|
||||
{item.i}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/dashboard/OrchestratorSessions", () => ({
|
||||
OrchestratorSessions: (): React.JSX.Element => (
|
||||
<div data-testid="orchestrator-sessions">Orchestrator Sessions</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/dashboard/QuickActions", () => ({
|
||||
QuickActions: (): React.JSX.Element => <div data-testid="quick-actions">Quick Actions</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/dashboard/ActivityFeed", () => ({
|
||||
ActivityFeed: (): React.JSX.Element => <div data-testid="activity-feed">Activity Feed</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/dashboard/TokenBudget", () => ({
|
||||
TokenBudget: (): React.JSX.Element => <div data-testid="token-budget">Token Budget</div>,
|
||||
}));
|
||||
|
||||
// Mock hooks and API calls
|
||||
// Mock hooks
|
||||
vi.mock("@/lib/hooks", () => ({
|
||||
useWorkspaceId: (): string | null => "ws-test-123",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/dashboard", () => ({
|
||||
fetchDashboardSummary: vi.fn().mockResolvedValue({
|
||||
metrics: {
|
||||
activeAgents: 5,
|
||||
tasksCompleted: 42,
|
||||
totalTasks: 100,
|
||||
tasksInProgress: 10,
|
||||
activeProjects: 3,
|
||||
errorRate: 0.5,
|
||||
},
|
||||
recentActivity: [],
|
||||
activeJobs: [],
|
||||
tokenBudget: [],
|
||||
}),
|
||||
}));
|
||||
// Mock layout API
|
||||
vi.mock("@/lib/api/layouts");
|
||||
|
||||
const mockExistingLayout: UserLayout = {
|
||||
id: "layout-1",
|
||||
workspaceId: "ws-test-123",
|
||||
userId: "user-1",
|
||||
name: "Default",
|
||||
isDefault: true,
|
||||
layout: [
|
||||
{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2 },
|
||||
{ i: "CalendarWidget-default", x: 4, y: 0, w: 4, h: 2 },
|
||||
],
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
describe("DashboardPage", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
||||
metrics: {
|
||||
activeAgents: 5,
|
||||
tasksCompleted: 42,
|
||||
totalTasks: 100,
|
||||
tasksInProgress: 10,
|
||||
activeProjects: 3,
|
||||
errorRate: 0.5,
|
||||
},
|
||||
recentActivity: [],
|
||||
activeJobs: [],
|
||||
tokenBudget: [],
|
||||
});
|
||||
|
||||
it("should render WidgetGrid with saved layout", async (): Promise<void> => {
|
||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("widget-grid")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("widget-TasksWidget-default")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("widget-CalendarWidget-default")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should create default layout when none exists", async (): Promise<void> => {
|
||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(null);
|
||||
vi.mocked(layoutsApi.createLayout).mockResolvedValue({
|
||||
...mockExistingLayout,
|
||||
layout: [{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2 }],
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(layoutsApi.createLayout).toHaveBeenCalledWith("ws-test-123", {
|
||||
name: "Default",
|
||||
isDefault: true,
|
||||
layout: expect.arrayContaining([
|
||||
expect.objectContaining({ i: "TasksWidget-default" }),
|
||||
]) as WidgetPlacement[],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the DashboardMetrics widget", async (): Promise<void> => {
|
||||
it("should show loading spinner initially", (): void => {
|
||||
// Never-resolving promise to test loading state
|
||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockReturnValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving
|
||||
new Promise(() => {})
|
||||
);
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should fall back to default layout on API error", async (): Promise<void> => {
|
||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("dashboard-metrics")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("widget-grid")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the OrchestratorSessions widget", async (): Promise<void> => {
|
||||
it("should render Dashboard heading", async (): Promise<void> => {
|
||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("orchestrator-sessions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the QuickActions widget", async (): Promise<void> => {
|
||||
it("should render Edit Layout button", async (): Promise<void> => {
|
||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("quick-actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit Layout")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the ActivityFeed widget", async (): Promise<void> => {
|
||||
render(<DashboardPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("activity-feed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("should toggle edit mode on button click", async (): Promise<void> => {
|
||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||
|
||||
it("should render the TokenBudget widget", async (): Promise<void> => {
|
||||
render(<DashboardPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("token-budget")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render error state when API fails", async (): Promise<void> => {
|
||||
vi.mocked(fetchDashboardSummary).mockRejectedValueOnce(new Error("Network error"));
|
||||
render(<DashboardPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByText("Failed to load dashboard data")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit Layout")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act((): void => {
|
||||
screen.getByText("Edit Layout").click();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Done")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("widget-grid").getAttribute("data-editing")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics";
|
||||
import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions";
|
||||
import { QuickActions } from "@/components/dashboard/QuickActions";
|
||||
import { ActivityFeed } from "@/components/dashboard/ActivityFeed";
|
||||
import { TokenBudget } from "@/components/dashboard/TokenBudget";
|
||||
import { fetchDashboardSummary } from "@/lib/api/dashboard";
|
||||
import type { DashboardSummaryResponse } from "@/lib/api/dashboard";
|
||||
import type { WidgetPlacement } from "@mosaic/shared";
|
||||
import { WidgetGrid } from "@/components/widgets/WidgetGrid";
|
||||
import { WidgetPicker } from "@/components/widgets/WidgetPicker";
|
||||
import { WidgetConfigDialog } from "@/components/widgets/WidgetConfigDialog";
|
||||
import { DEFAULT_LAYOUT } from "@/components/widgets/defaultLayout";
|
||||
import { fetchDefaultLayout, createLayout, updateLayout } from "@/lib/api/layouts";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
export default function DashboardPage(): ReactElement {
|
||||
const workspaceId = useWorkspaceId();
|
||||
const [data, setData] = useState<DashboardSummaryResponse | null>(null);
|
||||
const [layout, setLayout] = useState<WidgetPlacement[]>(DEFAULT_LAYOUT);
|
||||
const [layoutId, setLayoutId] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
const [configWidgetId, setConfigWidgetId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Debounce timer for auto-saving layout changes
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Load the user's default layout (or create one)
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
setIsLoading(false);
|
||||
@@ -24,48 +30,104 @@ export default function DashboardPage(): ReactElement {
|
||||
}
|
||||
|
||||
const wsId = workspaceId;
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const ac = new AbortController();
|
||||
|
||||
async function loadSummary(): Promise<void> {
|
||||
async function loadLayout(): Promise<void> {
|
||||
try {
|
||||
const summary = await fetchDashboardSummary(wsId);
|
||||
if (!cancelled) {
|
||||
setData(summary);
|
||||
const existing = await fetchDefaultLayout(wsId);
|
||||
if (ac.signal.aborted) return;
|
||||
|
||||
if (existing) {
|
||||
setLayout(existing.layout);
|
||||
setLayoutId(existing.id);
|
||||
} else {
|
||||
const created = await createLayout(wsId, {
|
||||
name: "Default",
|
||||
isDefault: true,
|
||||
layout: DEFAULT_LAYOUT,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- aborted can change during await
|
||||
if (ac.signal.aborted) return;
|
||||
setLayout(created.layout);
|
||||
setLayoutId(created.id);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("[Dashboard] Failed to fetch summary:", err);
|
||||
if (!cancelled) {
|
||||
setError("Failed to load dashboard data");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
console.error("[Dashboard] Failed to load layout:", err);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
void loadSummary();
|
||||
void loadLayout();
|
||||
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
ac.abort();
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
// Save layout changes with debounce
|
||||
const saveLayout = useCallback(
|
||||
(newLayout: WidgetPlacement[]) => {
|
||||
if (!workspaceId || !layoutId) return;
|
||||
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
void updateLayout(workspaceId, layoutId, { layout: newLayout }).catch((err: unknown) => {
|
||||
console.error("[Dashboard] Failed to save layout:", err);
|
||||
});
|
||||
}, 800);
|
||||
},
|
||||
[workspaceId, layoutId]
|
||||
);
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(newLayout: WidgetPlacement[]) => {
|
||||
setLayout(newLayout);
|
||||
saveLayout(newLayout);
|
||||
},
|
||||
[saveLayout]
|
||||
);
|
||||
|
||||
const handleRemoveWidget = useCallback(
|
||||
(widgetId: string) => {
|
||||
const updated = layout.filter((item) => item.i !== widgetId);
|
||||
setLayout(updated);
|
||||
saveLayout(updated);
|
||||
},
|
||||
[layout, saveLayout]
|
||||
);
|
||||
|
||||
const handleAddWidget = useCallback(
|
||||
(placement: WidgetPlacement) => {
|
||||
const updated = [...layout, placement];
|
||||
setLayout(updated);
|
||||
saveLayout(updated);
|
||||
},
|
||||
[layout, saveLayout]
|
||||
);
|
||||
|
||||
const handleResetLayout = useCallback((): void => {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
saveLayout(DEFAULT_LAYOUT);
|
||||
}, [saveLayout]);
|
||||
|
||||
const handleEditWidget = useCallback((widgetId: string): void => {
|
||||
setConfigWidgetId(widgetId);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<DashboardMetrics />
|
||||
<div className="dash-grid">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
||||
<OrchestratorSessions />
|
||||
<QuickActions />
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<ActivityFeed />
|
||||
<TokenBudget />
|
||||
</div>
|
||||
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: "var(--muted)" }}>
|
||||
Loading dashboard...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -73,32 +135,108 @@ export default function DashboardPage(): ReactElement {
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{error && (
|
||||
<div
|
||||
{/* Dashboard header with edit toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
marginBottom: 16,
|
||||
background: "rgba(229,72,77,0.1)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r)",
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text)",
|
||||
fontSize: "0.85rem",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<DashboardMetrics metrics={data?.metrics} />
|
||||
<div className="dash-grid">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
||||
<OrchestratorSessions jobs={data?.activeJobs} />
|
||||
<QuickActions />
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<ActivityFeed items={data?.recentActivity} />
|
||||
<TokenBudget budgets={data?.tokenBudget} />
|
||||
Dashboard
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleResetLayout}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: "var(--r)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "transparent",
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={(): void => {
|
||||
setIsPickerOpen(true);
|
||||
}}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: "var(--r)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "transparent",
|
||||
color: "var(--text-2)",
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
+ Add Widget
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={(): void => {
|
||||
setIsEditing((prev) => !prev);
|
||||
if (isEditing) setIsPickerOpen(false);
|
||||
}}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: "var(--r)",
|
||||
border: isEditing ? "1px solid var(--primary)" : "1px solid var(--border)",
|
||||
background: isEditing ? "var(--primary)" : "transparent",
|
||||
color: isEditing ? "#fff" : "var(--text-2)",
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{isEditing ? "Done" : "Edit Layout"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Widget grid */}
|
||||
<WidgetGrid
|
||||
layout={layout}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
{...(isEditing && { onRemoveWidget: handleRemoveWidget })}
|
||||
{...(isEditing && { onEditWidget: handleEditWidget })}
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
|
||||
{/* Widget config dialog */}
|
||||
{configWidgetId && (
|
||||
<WidgetConfigDialog
|
||||
widgetId={configWidgetId}
|
||||
open
|
||||
onClose={(): void => {
|
||||
setConfigWidgetId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Widget picker drawer */}
|
||||
<WidgetPicker
|
||||
open={isPickerOpen}
|
||||
onClose={(): void => {
|
||||
setIsPickerOpen(false);
|
||||
}}
|
||||
onAddWidget={handleAddWidget}
|
||||
currentLayout={layout}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
467
apps/web/src/app/(authenticated)/profile/page.tsx
Normal file
467
apps/web/src/app/(authenticated)/profile/page.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
interface UserPreferences {
|
||||
id: string;
|
||||
userId: string;
|
||||
theme: string;
|
||||
locale: string;
|
||||
timezone: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────
|
||||
|
||||
interface PreferenceRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function PreferenceRow({ label, value }: PreferenceRowProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "12px 0",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "0.9rem", color: "var(--text-2)" }}>{label}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text)",
|
||||
fontFamily: "var(--mono)",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreferencesSkeleton(): ReactElement {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 0",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 80,
|
||||
height: 16,
|
||||
borderRadius: 4,
|
||||
background: "var(--surface-2)",
|
||||
animation: "pulse 1.5s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 120,
|
||||
height: 16,
|
||||
borderRadius: 4,
|
||||
background: "var(--surface-2)",
|
||||
animation: "pulse 1.5s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page Component ──────────────────────────────────────────────
|
||||
|
||||
export default function ProfilePage(): ReactElement {
|
||||
const { user, signOut } = useAuth();
|
||||
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
|
||||
const [prefsLoading, setPrefsLoading] = useState(true);
|
||||
const [prefsError, setPrefsError] = useState<string | null>(null);
|
||||
const [signOutHovered, setSignOutHovered] = useState(false);
|
||||
const [settingsHovered, setSettingsHovered] = useState(false);
|
||||
|
||||
const loadPreferences = useCallback(async (): Promise<void> => {
|
||||
setPrefsLoading(true);
|
||||
setPrefsError(null);
|
||||
|
||||
try {
|
||||
const data = await apiGet<UserPreferences>("/users/me/preferences");
|
||||
setPreferences(data);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Could not load preferences";
|
||||
setPrefsError(message);
|
||||
} finally {
|
||||
setPrefsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPreferences();
|
||||
}, [loadPreferences]);
|
||||
|
||||
// User initials for avatar fallback
|
||||
const initials = user?.name
|
||||
? user.name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
: user?.email
|
||||
? (user.email[0] ?? "?").toUpperCase()
|
||||
: "?";
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
{/* ── Page Header ── */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.875rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Profile
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
color: "var(--muted)",
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
Your account information and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── User Info Card ── */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-xl)",
|
||||
padding: 28,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 20 }}>
|
||||
{/* Avatar (64px) */}
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: "50%",
|
||||
background: user?.image
|
||||
? "none"
|
||||
: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{user?.image ? (
|
||||
<img
|
||||
src={user.image}
|
||||
alt={user.name || user.email || "User avatar"}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 700,
|
||||
color: "#fff",
|
||||
letterSpacing: "0.02em",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name, email, role, status */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text)",
|
||||
margin: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{user?.name ?? "User"}
|
||||
</h2>
|
||||
|
||||
{/* Online indicator */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "var(--success)",
|
||||
boxShadow: "0 0 6px var(--success)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--success)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user?.email && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
color: "var(--muted)",
|
||||
margin: "4px 0 0 0",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{user?.workspaceRole && (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
marginTop: 8,
|
||||
padding: "3px 10px",
|
||||
borderRadius: "var(--r)",
|
||||
background: "rgba(47, 128, 255, 0.1)",
|
||||
color: "var(--ms-blue-400)",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{user.workspaceRole}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Preferences Section ── */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-xl)",
|
||||
padding: 28,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
margin: "0 0 16px 0",
|
||||
}}
|
||||
>
|
||||
Preferences
|
||||
</h3>
|
||||
|
||||
{prefsLoading ? (
|
||||
<PreferencesSkeleton />
|
||||
) : prefsError ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 20px",
|
||||
borderRadius: "var(--r)",
|
||||
background: "rgba(245, 158, 11, 0.08)",
|
||||
border: "1px solid rgba(245, 158, 11, 0.2)",
|
||||
color: "var(--text-2)",
|
||||
fontSize: "0.85rem",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 500 }}>Preferences unavailable</span>
|
||||
<span style={{ color: "var(--muted)", marginLeft: 8 }}>— {prefsError}</span>
|
||||
</div>
|
||||
) : preferences ? (
|
||||
<div>
|
||||
<PreferenceRow label="Theme" value={preferences.theme} />
|
||||
<PreferenceRow label="Locale" value={preferences.locale} />
|
||||
<PreferenceRow label="Timezone" value={preferences.timezone ?? "Not set"} />
|
||||
{Object.keys(preferences.settings).length > 0 && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text-2)",
|
||||
margin: "16px 0 8px 0",
|
||||
}}
|
||||
>
|
||||
Custom Settings
|
||||
</div>
|
||||
{Object.entries(preferences.settings).map(([key, value]) => (
|
||||
<PreferenceRow key={key} label={key} value={String(value)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
color: "var(--muted)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
No preferences configured yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Account Actions ── */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-xl)",
|
||||
padding: 28,
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
margin: "0 0 16px 0",
|
||||
}}
|
||||
>
|
||||
Account
|
||||
</h3>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* Settings link */}
|
||||
<Link
|
||||
href="/settings"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "10px 20px",
|
||||
borderRadius: "var(--r)",
|
||||
background: settingsHovered ? "var(--surface-2)" : "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text)",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.15s ease, border-color 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setSettingsHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setSettingsHovered(false);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="8" r="2.5" />
|
||||
<path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.05 3.05l1.06 1.06M11.89 11.89l1.06 1.06M3.05 12.95l1.06-1.06M11.89 4.11l1.06-1.06" />
|
||||
</svg>
|
||||
Settings
|
||||
</Link>
|
||||
|
||||
{/* Sign Out button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
void signOut();
|
||||
}}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "10px 20px",
|
||||
borderRadius: "var(--r)",
|
||||
background: signOutHovered ? "rgba(239, 68, 68, 0.1)" : "transparent",
|
||||
border: "1px solid var(--danger)",
|
||||
color: "var(--danger)",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
transition: "background 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setSignOutHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setSignOutHovered(false);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M10 11l4-4-4-4M14 8H6" />
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
809
apps/web/src/app/(authenticated)/projects/page.tsx
Normal file
809
apps/web/src/app/(authenticated)/projects/page.tsx
Normal file
@@ -0,0 +1,809 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { ReactElement, SyntheticEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { fetchProjects, createProject, deleteProject, ProjectStatus } from "@/lib/api/projects";
|
||||
import type { Project, CreateProjectDto } from "@/lib/api/projects";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Status badge helpers
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface StatusStyle {
|
||||
label: string;
|
||||
bg: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function getStatusStyle(status: ProjectStatus): StatusStyle {
|
||||
switch (status) {
|
||||
case ProjectStatus.PLANNING:
|
||||
return { label: "Planning", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" };
|
||||
case ProjectStatus.ACTIVE:
|
||||
return { label: "Active", bg: "rgba(20,184,166,0.15)", color: "var(--success)" };
|
||||
case ProjectStatus.PAUSED:
|
||||
return { label: "Paused", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
||||
case ProjectStatus.COMPLETED:
|
||||
return { label: "Completed", bg: "rgba(139,92,246,0.15)", color: "var(--purple)" };
|
||||
case ProjectStatus.ARCHIVED:
|
||||
return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||
default:
|
||||
return { label: String(status), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
ProjectCard
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: Project;
|
||||
onDelete: (id: string) => void;
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
function ProjectCard({ project, onDelete, onClick }: ProjectCardProps): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const status = getStatusStyle(project.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
onClick(project.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick(project.id);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: `1px solid ${hovered ? "var(--primary)" : "var(--border)"}`,
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: 20,
|
||||
cursor: "pointer",
|
||||
transition: "border-color 0.2s var(--ease)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Header row: name + delete button */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
fontSize: "1rem",
|
||||
margin: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{project.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
aria-label={`Delete project ${project.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(project.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 4,
|
||||
borderRadius: "var(--r-sm)",
|
||||
color: "var(--muted)",
|
||||
transition: "color 0.15s, background 0.15s",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "var(--danger)";
|
||||
e.currentTarget.style.background = "rgba(229,72,77,0.1)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "var(--muted)";
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{project.description ? (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.85rem",
|
||||
margin: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{project.description}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ color: "var(--muted)", fontSize: "0.85rem", margin: 0, fontStyle: "italic" }}>
|
||||
No description
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer: status + timestamps */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: "auto",
|
||||
}}
|
||||
>
|
||||
{/* Status badge */}
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 10px",
|
||||
borderRadius: "var(--r)",
|
||||
background: status.bg,
|
||||
color: status.color,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
|
||||
{/* Timestamps */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--muted)",
|
||||
fontFamily: "var(--mono)",
|
||||
}}
|
||||
>
|
||||
{formatTimestamp(project.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Create Project Dialog
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface CreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: CreateProjectDto) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function CreateProjectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: CreateDialogProps): ReactElement {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
function resetForm(): void {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setFormError("Project name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: CreateProjectDto = { name: trimmedName };
|
||||
const trimmedDesc = description.trim();
|
||||
if (trimmedDesc) {
|
||||
payload.description = trimmedDesc;
|
||||
}
|
||||
await onSubmit(payload);
|
||||
resetForm();
|
||||
} catch (err: unknown) {
|
||||
setFormError(err instanceof Error ? err.message : "Failed to create project.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) resetForm();
|
||||
onOpenChange(isOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
border: "1px solid var(--border)",
|
||||
padding: 24,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<span style={{ color: "var(--text)" }}>New Project</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span style={{ color: "var(--muted)" }}>
|
||||
Give your project a name and optional description.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(e);
|
||||
}}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
{/* Name */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
htmlFor="project-name"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-2)",
|
||||
}}
|
||||
>
|
||||
Name <span style={{ color: "var(--danger)" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="project-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
}}
|
||||
placeholder="e.g. Website Redesign"
|
||||
maxLength={255}
|
||||
autoFocus
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r)",
|
||||
color: "var(--text)",
|
||||
fontSize: "0.9rem",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
htmlFor="project-description"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-2)",
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="project-description"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
}}
|
||||
placeholder="A brief summary of this project..."
|
||||
rows={3}
|
||||
maxLength={10000}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r)",
|
||||
color: "var(--text)",
|
||||
fontSize: "0.9rem",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form error */}
|
||||
{formError !== null && (
|
||||
<p style={{ color: "var(--danger)", fontSize: "0.85rem", margin: "0 0 12px" }}>
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r)",
|
||||
color: "var(--text-2)",
|
||||
fontSize: "0.85rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !name.trim()}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--primary)",
|
||||
border: "none",
|
||||
borderRadius: "var(--r)",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: isSubmitting || !name.trim() ? "not-allowed" : "pointer",
|
||||
opacity: isSubmitting || !name.trim() ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create Project"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Delete Confirmation Dialog
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface DeleteDialogProps {
|
||||
open: boolean;
|
||||
projectName: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
function DeleteConfirmDialog({
|
||||
open,
|
||||
projectName,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isDeleting,
|
||||
}: DeleteDialogProps): ReactElement {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) onCancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
border: "1px solid var(--border)",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<span style={{ color: "var(--text)" }}>Delete Project</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span style={{ color: "var(--muted)" }}>
|
||||
{"This will permanently delete "}
|
||||
<strong style={{ color: "var(--text)" }}>{projectName}</strong>
|
||||
{". This action cannot be undone."}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r)",
|
||||
color: "var(--text-2)",
|
||||
fontSize: "0.85rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--danger)",
|
||||
border: "none",
|
||||
borderRadius: "var(--r)",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: isDeleting ? "not-allowed" : "pointer",
|
||||
opacity: isDeleting ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Projects Page
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
export default function ProjectsPage(): ReactElement {
|
||||
const router = useRouter();
|
||||
const workspaceId = useWorkspaceId();
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Create dialog state
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// Delete dialog state
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const loadProjects = useCallback(async (wsId: string | null): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchProjects(wsId ?? undefined);
|
||||
setProjects(data);
|
||||
} catch (err: unknown) {
|
||||
console.error("[Projects] Failed to fetch projects:", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Something went wrong loading projects. You could try again when ready."
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const wsId = workspaceId;
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchProjects(wsId);
|
||||
if (!cancelled) {
|
||||
setProjects(data);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("[Projects] Failed to fetch projects:", err);
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Something went wrong loading projects. You could try again when ready."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
function handleRetry(): void {
|
||||
void loadProjects(workspaceId);
|
||||
}
|
||||
|
||||
async function handleCreate(data: CreateProjectDto): Promise<void> {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createProject(data, workspaceId ?? undefined);
|
||||
setCreateOpen(false);
|
||||
void loadProjects(workspaceId);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteRequest(projectId: string): void {
|
||||
const target = projects.find((p) => p.id === projectId);
|
||||
if (target) {
|
||||
setDeleteTarget(target);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm(): Promise<void> {
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteProject(deleteTarget.id, workspaceId ?? undefined);
|
||||
setDeleteTarget(null);
|
||||
void loadProjects(workspaceId);
|
||||
} catch (err: unknown) {
|
||||
console.error("[Projects] Failed to delete project:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to delete project.");
|
||||
setDeleteTarget(null);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(projectId: string): void {
|
||||
router.push(`/projects/${projectId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8" style={{ maxWidth: 960 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 32,
|
||||
flexWrap: "wrap",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.875rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Projects
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
color: "var(--muted)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
Organize and track your work across different initiatives
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 16px",
|
||||
background: "var(--primary)",
|
||||
border: "none",
|
||||
borderRadius: "var(--r)",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<MosaicSpinner label="Loading projects..." />
|
||||
</div>
|
||||
) : error !== null ? (
|
||||
/* Error */
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: 32,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--danger)", margin: "0 0 16px" }}>{error}</p>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--danger)",
|
||||
border: "none",
|
||||
borderRadius: "var(--r)",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
/* Empty */
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: 48,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--muted)", margin: "0 0 16px", fontSize: "0.9rem" }}>
|
||||
No projects yet. Create your first project to get started.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 16px",
|
||||
background: "var(--primary)",
|
||||
border: "none",
|
||||
borderRadius: "var(--r)",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Create Project
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Projects grid */
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onDelete={handleDeleteRequest}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Dialog */}
|
||||
<CreateProjectDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onSubmit={handleCreate}
|
||||
isSubmitting={isCreating}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<DeleteConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
projectName={deleteTarget?.name ?? ""}
|
||||
onConfirm={() => {
|
||||
void handleDeleteConfirm();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
324
apps/web/src/app/(authenticated)/settings/appearance/page.tsx
Normal file
324
apps/web/src/app/(authenticated)/settings/appearance/page.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "@/providers/ThemeProvider";
|
||||
import { getAllThemes, type ThemeDefinition } from "@/themes";
|
||||
import { apiPatch } from "@/lib/api/client";
|
||||
|
||||
function ThemeCard({
|
||||
theme,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: {
|
||||
theme: ThemeDefinition;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
}): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
borderRadius: "var(--r-lg)",
|
||||
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
|
||||
border: isActive
|
||||
? "2px solid var(--primary)"
|
||||
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "all 0.15s ease",
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
}}
|
||||
aria-label={`Select ${theme.name} theme`}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{/* Color preview swatches */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 0,
|
||||
borderRadius: "var(--r)",
|
||||
overflow: "hidden",
|
||||
height: 48,
|
||||
width: "100%",
|
||||
border: "1px solid rgba(128, 128, 128, 0.15)",
|
||||
}}
|
||||
>
|
||||
{theme.colorPreview.map((color, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Theme info */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{theme.name}
|
||||
</span>
|
||||
{isActive && (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="13 4 6 12 3 9" />
|
||||
</svg>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "var(--r-sm)",
|
||||
background: theme.isDark ? "rgba(128,128,128,0.15)" : "rgba(245,158,11,0.12)",
|
||||
color: theme.isDark ? "var(--muted)" : "var(--warn)",
|
||||
fontWeight: 500,
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
>
|
||||
{theme.isDark ? "Dark" : "Light"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.78rem",
|
||||
color: "var(--muted)",
|
||||
margin: 0,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{theme.description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemThemeCard({
|
||||
isActive,
|
||||
onSelect,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
}): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
borderRadius: "var(--r-lg)",
|
||||
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
|
||||
border: isActive
|
||||
? "2px solid var(--primary)"
|
||||
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "all 0.15s ease",
|
||||
width: "100%",
|
||||
}}
|
||||
aria-label="Use system theme preference"
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{/* Split preview (dark | light) */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 0,
|
||||
borderRadius: "var(--r)",
|
||||
overflow: "hidden",
|
||||
height: 48,
|
||||
width: "100%",
|
||||
border: "1px solid rgba(128, 128, 128, 0.15)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, background: "#0f141d" }} />
|
||||
<div style={{ flex: 1, background: "#1b2331" }} />
|
||||
<div style={{ flex: 1, background: "#f0f4fc" }} />
|
||||
<div style={{ flex: 1, background: "#dde4f2" }} />
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "linear-gradient(135deg, #2f80ff 50%, #8b5cf6 50%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "var(--text)" }}>System</span>
|
||||
{isActive && (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="13 4 6 12 3 9" />
|
||||
</svg>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "var(--r-sm)",
|
||||
background: "rgba(47, 128, 255, 0.12)",
|
||||
color: "var(--primary-l)",
|
||||
fontWeight: 500,
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
>
|
||||
Auto
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.78rem",
|
||||
color: "var(--muted)",
|
||||
margin: 0,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
Follows your operating system appearance preference
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppearanceSettingsPage(): ReactElement {
|
||||
const { theme: preference, setTheme: setLocalTheme } = useTheme();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const allThemes = getAllThemes();
|
||||
|
||||
const handleThemeSelect = useCallback(
|
||||
async (themeId: string) => {
|
||||
setLocalTheme(themeId);
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiPatch("/users/me/preferences", { theme: themeId });
|
||||
} catch {
|
||||
// Theme is still applied locally even if API save fails
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[setLocalTheme]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Breadcrumb */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Link
|
||||
href="/settings"
|
||||
style={{
|
||||
fontSize: "0.83rem",
|
||||
color: "var(--muted)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<span style={{ fontSize: "0.83rem", color: "var(--muted)", margin: "0 6px" }}>/</span>
|
||||
<span style={{ fontSize: "0.83rem", color: "var(--text-2)" }}>Appearance</span>
|
||||
</div>
|
||||
|
||||
{/* Page header */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.875rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Appearance
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
color: "var(--muted)",
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
Choose a theme for the Mosaic interface
|
||||
{saving && (
|
||||
<span style={{ marginLeft: 12, color: "var(--primary-l)", fontStyle: "italic" }}>
|
||||
Saving...
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Theme grid */}
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||
}}
|
||||
>
|
||||
{/* System option first */}
|
||||
<SystemThemeCard
|
||||
isActive={preference === "system"}
|
||||
onSelect={() => void handleThemeSelect("system")}
|
||||
/>
|
||||
|
||||
{/* All registered themes */}
|
||||
{allThemes.map((t) => (
|
||||
<ThemeCard
|
||||
key={t.id}
|
||||
theme={t}
|
||||
isActive={preference === t.id}
|
||||
onSelect={() => void handleThemeSelect(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
264
apps/web/src/app/(authenticated)/settings/page.tsx
Normal file
264
apps/web/src/app/(authenticated)/settings/page.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface CategoryConfig {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
accent: string;
|
||||
iconBg: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface SettingsCategoryCardProps {
|
||||
category: CategoryConfig;
|
||||
}
|
||||
|
||||
function SettingsCategoryCard({ category }: SettingsCategoryCardProps): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<Link href={category.href} style={{ textDecoration: "none" }}>
|
||||
<div
|
||||
onMouseEnter={(): void => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
background: hovered ? "var(--surface-2)" : "var(--surface)",
|
||||
border: `1px solid ${hovered ? category.accent : "var(--border)"}`,
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: 20,
|
||||
transition: "background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease",
|
||||
boxShadow: hovered ? "0 4px 16px rgba(0,0,0,0.2)" : "none",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Icon well */}
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "var(--r)",
|
||||
background: category.iconBg,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: category.accent,
|
||||
transition: "transform 0.15s ease",
|
||||
transform: hovered ? "scale(1.05)" : "scale(1)",
|
||||
}}
|
||||
>
|
||||
{category.icon}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div style={{ fontSize: "1rem", fontWeight: 700, color: "var(--text)" }}>
|
||||
{category.title}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.83rem",
|
||||
color: "var(--muted)",
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{category.description}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.83rem",
|
||||
color: hovered ? category.accent : "var(--muted)",
|
||||
fontWeight: 500,
|
||||
marginTop: "auto",
|
||||
transition: "color 0.15s ease",
|
||||
}}
|
||||
>
|
||||
Manage →
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const categories: CategoryConfig[] = [
|
||||
{
|
||||
title: "Appearance",
|
||||
description:
|
||||
"Choose a theme for the interface. Switch between Dark, Light, Nord, Dracula, and more.",
|
||||
href: "/settings/appearance",
|
||||
accent: "var(--ms-pink-500)",
|
||||
iconBg: "rgba(236, 72, 153, 0.12)",
|
||||
icon: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" />
|
||||
<path d="M10 2.5v15" />
|
||||
<path d="M10 2.5a7.5 7.5 0 0 1 0 15" fill="currentColor" opacity="0.15" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Credentials",
|
||||
description:
|
||||
"Securely store and manage API keys, tokens, and passwords used by agents and integrations.",
|
||||
href: "/settings/credentials",
|
||||
accent: "var(--ms-blue-400)",
|
||||
iconBg: "rgba(47, 128, 255, 0.12)",
|
||||
icon: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="5" y="9" width="10" height="8" rx="1.5" />
|
||||
<path d="M7 9V6a3 3 0 0 1 6 0v3" />
|
||||
<circle cx="10" cy="13" r="1" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Domains",
|
||||
description:
|
||||
"Organize tasks and projects by life areas or functional domains within your workspace.",
|
||||
href: "/settings/domains",
|
||||
accent: "var(--ms-teal-400)",
|
||||
iconBg: "rgba(20, 184, 166, 0.12)",
|
||||
icon: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" />
|
||||
<line x1="2.5" y1="10" x2="17.5" y2="10" />
|
||||
<path d="M10 2.5c2 2.5 3 5 3 7.5s-1 5-3 7.5" />
|
||||
<path d="M10 2.5c-2 2.5-3 5-3 7.5s1 5 3 7.5" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "AI Personalities",
|
||||
description:
|
||||
"Customize how the AI assistant communicates \u2014 tone, formality, and response style.",
|
||||
href: "/settings/personalities",
|
||||
accent: "var(--ms-purple-400)",
|
||||
iconBg: "rgba(139, 92, 246, 0.12)",
|
||||
icon: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="6" r="3" />
|
||||
<path d="M4 17c0-3.3 2.7-6 6-6s6 2.7 6 6" />
|
||||
<path d="M14 10l1.5 1.5 3-3" stroke="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Workspaces",
|
||||
description:
|
||||
"Create and manage workspaces to organize projects and collaborate with your team.",
|
||||
href: "/settings/workspaces",
|
||||
accent: "var(--ms-amber-400)",
|
||||
iconBg: "rgba(245, 158, 11, 0.12)",
|
||||
icon: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="2" />
|
||||
<circle cx="4" cy="5" r="1.5" />
|
||||
<circle cx="16" cy="5" r="1.5" />
|
||||
<circle cx="16" cy="15" r="1.5" />
|
||||
<line x1="8.3" y1="8.7" x2="5.3" y2="6.2" />
|
||||
<line x1="11.7" y1="8.7" x2="14.7" y2="6.2" />
|
||||
<line x1="11.7" y1="11.3" x2="14.7" y2="13.8" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsPage(): ReactElement {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
{/* Page header */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.875rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
color: "var(--muted)",
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
Configure your workspace, credentials, and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Category grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{categories.map((category) => (
|
||||
<SettingsCategoryCard key={category.href} category={category} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
import TasksPage from "./page";
|
||||
|
||||
// Mock the TaskList component
|
||||
@@ -9,21 +12,121 @@ vi.mock("@/components/tasks/TaskList", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock MosaicSpinner
|
||||
vi.mock("@/components/ui/MosaicSpinner", () => ({
|
||||
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
|
||||
<div data-testid="mosaic-spinner">{label ?? "Loading..."}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock useWorkspaceId
|
||||
const mockUseWorkspaceId = vi.fn<() => string | null>();
|
||||
vi.mock("@/lib/hooks", () => ({
|
||||
useWorkspaceId: (): string | null => mockUseWorkspaceId(),
|
||||
}));
|
||||
|
||||
// Mock fetchTasks
|
||||
const mockFetchTasks = vi.fn<() => Promise<Task[]>>();
|
||||
vi.mock("@/lib/api/tasks", () => ({
|
||||
fetchTasks: (...args: unknown[]): Promise<Task[]> => mockFetchTasks(...(args as [])),
|
||||
}));
|
||||
|
||||
const fakeTasks: Task[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Test task 1",
|
||||
description: "Description 1",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.HIGH,
|
||||
dueDate: new Date("2026-02-01"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "ws-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
title: "Test task 2",
|
||||
description: "Description 2",
|
||||
status: TaskStatus.NOT_STARTED,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
dueDate: new Date("2026-02-02"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "ws-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
];
|
||||
|
||||
describe("TasksPage", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
mockUseWorkspaceId.mockReturnValue("ws-1");
|
||||
mockFetchTasks.mockResolvedValue(fakeTasks);
|
||||
});
|
||||
|
||||
it("should render the page title", (): void => {
|
||||
render(<TasksPage />);
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks");
|
||||
});
|
||||
|
||||
it("should show loading state initially", (): void => {
|
||||
it("should show loading spinner initially", (): void => {
|
||||
// Never resolve so we stay in loading state
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
mockFetchTasks.mockReturnValue(new Promise<Task[]>(() => {}));
|
||||
render(<TasksPage />);
|
||||
expect(screen.getByTestId("task-list")).toHaveTextContent("Loading");
|
||||
expect(screen.getByTestId("mosaic-spinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the TaskList with tasks after loading", async (): Promise<void> => {
|
||||
render(<TasksPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("task-list")).toHaveTextContent("4 tasks");
|
||||
expect(screen.getByTestId("task-list")).toHaveTextContent("2 tasks");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show empty state when no tasks exist", async (): Promise<void> => {
|
||||
mockFetchTasks.mockResolvedValue([]);
|
||||
render(<TasksPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByText("No tasks found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show error state on API failure", async (): Promise<void> => {
|
||||
mockFetchTasks.mockRejectedValue(new Error("Network error"));
|
||||
render(<TasksPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should retry fetching on retry button click", async (): Promise<void> => {
|
||||
mockFetchTasks.mockRejectedValueOnce(new Error("Network error"));
|
||||
render(<TasksPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
mockFetchTasks.mockResolvedValueOnce(fakeTasks);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: /try again/i }));
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("task-list")).toHaveTextContent("2 tasks");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,4 +140,14 @@ describe("TasksPage", (): void => {
|
||||
render(<TasksPage />);
|
||||
expect(screen.getByText("Organize your work at your own pace")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not fetch when workspace ID is not available", async (): Promise<void> => {
|
||||
mockUseWorkspaceId.mockReturnValue(null);
|
||||
render(<TasksPage />);
|
||||
|
||||
// Wait a tick to ensure useEffect ran
|
||||
await waitFor((): void => {
|
||||
expect(mockFetchTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,57 +4,123 @@ import { useState, useEffect } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { TaskList } from "@/components/tasks/TaskList";
|
||||
import { mockTasks } from "@/lib/api/tasks";
|
||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||
import { fetchTasks } from "@/lib/api/tasks";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
import type { Task } from "@mosaic/shared";
|
||||
|
||||
export default function TasksPage(): ReactElement {
|
||||
const workspaceId = useWorkspaceId();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTasks();
|
||||
}, []);
|
||||
|
||||
async function loadTasks(): Promise<void> {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: Replace with real API call when backend is ready
|
||||
// const data = await fetchTasks();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
setTasks(mockTasks);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "We had trouble loading your tasks. Please try again when you're ready."
|
||||
);
|
||||
} finally {
|
||||
if (!workspaceId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
async function loadTasks(): Promise<void> {
|
||||
try {
|
||||
const filters = workspaceId !== null ? { workspaceId } : {};
|
||||
const data = await fetchTasks(filters);
|
||||
if (!cancelled) {
|
||||
setTasks(data);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("[Tasks] Failed to fetch tasks:", err);
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "We had trouble loading your tasks. Please try again when you're ready."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadTasks();
|
||||
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
function handleRetry(): void {
|
||||
if (!workspaceId) return;
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
fetchTasks({ workspaceId })
|
||||
.then((data) => {
|
||||
setTasks(data);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("[Tasks] Retry failed:", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "We had trouble loading your tasks. Please try again when you're ready."
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Tasks</h1>
|
||||
<p className="text-gray-600 mt-2">Organize your work at your own pace</p>
|
||||
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||
Tasks
|
||||
</h1>
|
||||
<p className="mt-2" style={{ color: "var(--text-muted)" }}>
|
||||
Organize your work at your own pace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error !== null ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
||||
<p className="text-amber-800">{error}</p>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<MosaicSpinner label="Loading tasks..." />
|
||||
</div>
|
||||
) : error !== null ? (
|
||||
<div
|
||||
className="rounded-lg p-6 text-center"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--danger)" }}>{error}</p>
|
||||
<button
|
||||
onClick={() => void loadTasks()}
|
||||
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
||||
onClick={handleRetry}
|
||||
className="mt-4 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||
style={{ background: "var(--danger)" }}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div
|
||||
className="rounded-lg p-8 text-center"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--text-muted)" }}>No tasks found</p>
|
||||
</div>
|
||||
) : (
|
||||
<TaskList tasks={tasks} isLoading={isLoading} />
|
||||
<TaskList tasks={tasks} isLoading={false} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
1085
apps/web/src/app/(authenticated)/workspace/page.tsx
Normal file
1085
apps/web/src/app/(authenticated)/workspace/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -765,6 +765,28 @@ body::before {
|
||||
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
|
||||
----------------------------------------------------------------------------- */
|
||||
|
||||
175
apps/web/src/app/not-found.tsx
Normal file
175
apps/web/src/app/not-found.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "404 — Page Not Found | Mosaic Stack",
|
||||
};
|
||||
|
||||
export default function NotFound(): ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
background: "var(--bg)",
|
||||
padding: "24px",
|
||||
textAlign: "center",
|
||||
gap: "32px",
|
||||
}}
|
||||
>
|
||||
{/* Mosaic logo mark — inline spans replicating the 5-element logo */}
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
role="img"
|
||||
aria-label="Mosaic logo"
|
||||
>
|
||||
{/* Top-left: blue */}
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 19,
|
||||
height: 19,
|
||||
borderRadius: 4,
|
||||
background: "var(--ms-blue-500)",
|
||||
}}
|
||||
/>
|
||||
{/* Top-right: purple */}
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: 19,
|
||||
height: 19,
|
||||
borderRadius: 4,
|
||||
background: "var(--ms-purple-500)",
|
||||
}}
|
||||
/>
|
||||
{/* Bottom-right: teal */}
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 19,
|
||||
height: 19,
|
||||
borderRadius: 4,
|
||||
background: "var(--ms-teal-500)",
|
||||
}}
|
||||
/>
|
||||
{/* Bottom-left: amber */}
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: 19,
|
||||
height: 19,
|
||||
borderRadius: 4,
|
||||
background: "var(--ms-amber-500)",
|
||||
}}
|
||||
/>
|
||||
{/* Center: pink circle */}
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 15,
|
||||
height: 15,
|
||||
borderRadius: "50%",
|
||||
background: "var(--ms-pink-500)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 404 gradient text */}
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: "6rem",
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
margin: 0,
|
||||
background: "linear-gradient(135deg, var(--ms-blue-400), var(--ms-purple-500))",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
backgroundClip: "text",
|
||||
}}
|
||||
>
|
||||
404
|
||||
</h1>
|
||||
|
||||
{/* Heading + description */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
margin: 0,
|
||||
letterSpacing: "-0.025em",
|
||||
}}
|
||||
>
|
||||
Page not found
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.9375rem",
|
||||
color: "var(--muted)",
|
||||
margin: 0,
|
||||
maxWidth: "400px",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dashboard link styled as button */}
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "10px 24px",
|
||||
background: "var(--ms-blue-500)",
|
||||
color: "#ffffff",
|
||||
borderRadius: "var(--r)",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
transition: "opacity 0.15s ease",
|
||||
}}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
|
||||
{/* Subtle status footer */}
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--muted)",
|
||||
margin: 0,
|
||||
opacity: 0.6,
|
||||
}}
|
||||
>
|
||||
HTTP 404 — Not Found
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import Home from "./page";
|
||||
|
||||
// Mock Next.js navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: (): {
|
||||
push: typeof mockPush;
|
||||
replace: ReturnType<typeof vi.fn>;
|
||||
prefetch: ReturnType<typeof vi.fn>;
|
||||
} => ({
|
||||
push: mockPush,
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock auth context
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: (): {
|
||||
user: null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
signOut: ReturnType<typeof vi.fn>;
|
||||
refreshSession: ReturnType<typeof vi.fn>;
|
||||
} => ({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
signOut: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Home", (): void => {
|
||||
beforeEach((): void => {
|
||||
mockPush.mockClear();
|
||||
});
|
||||
|
||||
it("should render loading spinner", (): void => {
|
||||
const { container } = render(<Home />);
|
||||
// The home page shows a loading spinner while redirecting
|
||||
const spinner = container.querySelector(".animate-spin");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect unauthenticated users to login", (): void => {
|
||||
render(<Home />);
|
||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
|
||||
export default function Home(): ReactElement {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
if (isAuthenticated) {
|
||||
router.push("/tasks");
|
||||
} else {
|
||||
router.push("/login");
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,10 +64,12 @@ function createMockUseChatReturn(
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
conversationId: null,
|
||||
conversationTitle: null,
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
abortStream: vi.fn(),
|
||||
loadConversation: vi.fn().mockResolvedValue(undefined),
|
||||
startNewConversation: vi.fn(),
|
||||
setMessages: vi.fn(),
|
||||
|
||||
@@ -59,14 +59,15 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
|
||||
// Use the chat hook for state management
|
||||
const {
|
||||
messages,
|
||||
isLoading: isChatLoading,
|
||||
isStreaming,
|
||||
error,
|
||||
conversationId,
|
||||
conversationTitle,
|
||||
sendMessage,
|
||||
abortStream,
|
||||
loadConversation,
|
||||
startNewConversation,
|
||||
clearError,
|
||||
@@ -75,15 +76,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
||||
});
|
||||
|
||||
// Connect to WebSocket for real-time updates (when we have a user)
|
||||
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 { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(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 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, () => ({
|
||||
loadConversation: async (cId: string): Promise<void> => {
|
||||
await loadConversation(cId);
|
||||
@@ -110,7 +106,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
// Notify parent of conversation changes
|
||||
useEffect(() => {
|
||||
if (conversationId && conversationTitle) {
|
||||
onConversationChange?.(conversationId, {
|
||||
@@ -125,7 +120,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
}
|
||||
}, [conversationId, conversationTitle, initialProjectId, onConversationChange]);
|
||||
|
||||
// Global keyboard shortcut: Ctrl+/ to focus input
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
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(() => {
|
||||
if (isChatLoading) {
|
||||
// Show first quip after 3 seconds
|
||||
if (isChatLoading && !isStreaming) {
|
||||
quipTimerRef.current = setTimeout(() => {
|
||||
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
||||
}, 3000);
|
||||
|
||||
// Change quip every 5 seconds
|
||||
quipIntervalRef.current = setInterval(() => {
|
||||
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
||||
}, 5000);
|
||||
} else {
|
||||
// Clear timers when loading stops
|
||||
if (quipTimerRef.current) {
|
||||
clearTimeout(quipTimerRef.current);
|
||||
quipTimerRef.current = null;
|
||||
@@ -168,7 +159,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
if (quipTimerRef.current) clearTimeout(quipTimerRef.current);
|
||||
if (quipIntervalRef.current) clearInterval(quipIntervalRef.current);
|
||||
};
|
||||
}, [isChatLoading]);
|
||||
}, [isChatLoading, isStreaming]);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
@@ -177,7 +168,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
[sendMessage]
|
||||
);
|
||||
|
||||
// Show loading state while auth is loading
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div
|
||||
@@ -227,6 +217,8 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
<MessageList
|
||||
messages={messages as (Message & { thinking?: string })[]}
|
||||
isLoading={isChatLoading}
|
||||
isStreaming={isStreaming}
|
||||
{...(streamingMessageId != null ? { streamingMessageId } : {})}
|
||||
loadingQuip={loadingQuip}
|
||||
/>
|
||||
<div ref={messagesEndRef} />
|
||||
@@ -294,6 +286,8 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
onSend={handleSendMessage}
|
||||
disabled={isChatLoading || !user}
|
||||
inputRef={inputRef}
|
||||
isStreaming={isStreaming}
|
||||
onStopStreaming={abortStream}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,13 +7,20 @@ interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
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 [version, setVersion] = useState<string | null>(null);
|
||||
|
||||
// Fetch version from static version.json (generated at build time)
|
||||
useEffect(() => {
|
||||
interface VersionData {
|
||||
version?: string;
|
||||
@@ -24,7 +31,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
.then((res) => res.json() as Promise<VersionData>)
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
// Format as "version+commit" for full build identification
|
||||
const fullVersion = data.commit ? `${data.version}+${data.commit}` : data.version;
|
||||
setVersion(fullVersion);
|
||||
}
|
||||
@@ -35,20 +41,22 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (message.trim() && !disabled) {
|
||||
if (message.trim() && !disabled && !isStreaming) {
|
||||
onSend(message);
|
||||
setMessage("");
|
||||
}
|
||||
}, [message, onSend, disabled]);
|
||||
}, [message, onSend, disabled, isStreaming]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
onStopStreaming?.();
|
||||
}, [onStopStreaming]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Enter to send (without Shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
// Ctrl/Cmd + Enter to send (alternative)
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
@@ -61,6 +69,7 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
const maxCharacters = 4000;
|
||||
const isNearLimit = characterCount > maxCharacters * 0.9;
|
||||
const isOverLimit = characterCount > maxCharacters;
|
||||
const isInputDisabled = disabled ?? false;
|
||||
|
||||
return (
|
||||
<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"
|
||||
style={{
|
||||
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
|
||||
@@ -79,8 +91,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
setMessage(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
disabled={disabled}
|
||||
placeholder={isStreaming ? "AI is responding..." : "Type a message..."}
|
||||
disabled={isInputDisabled || isStreaming}
|
||||
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"
|
||||
style={{
|
||||
@@ -97,28 +109,47 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
aria-describedby="input-help"
|
||||
/>
|
||||
|
||||
{/* Send Button */}
|
||||
{/* Send / Stop Button */}
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={(disabled ?? !message.trim()) || isOverLimit}
|
||||
className="btn-primary btn-sm rounded-md"
|
||||
style={{
|
||||
opacity: disabled || !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}
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="btn-sm rounded-md flex items-center gap-1.5"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--semantic-error))",
|
||||
color: "white",
|
||||
padding: "0.25rem 0.75rem",
|
||||
}}
|
||||
aria-label="Stop generating"
|
||||
title="Stop generating"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Send</span>
|
||||
</button>
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="1" />
|
||||
</svg>
|
||||
<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>
|
||||
|
||||
@@ -128,7 +159,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
id="input-help"
|
||||
>
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="hidden items-center gap-4 sm:flex">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="kbd">Enter</span>
|
||||
@@ -142,10 +172,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile hint */}
|
||||
<div className="sm:hidden">Tap send or press Enter</div>
|
||||
|
||||
{/* Character Count */}
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Message } from "@/hooks/useChat";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
isStreaming?: boolean;
|
||||
streamingMessageId?: string;
|
||||
loadingQuip?: string | null;
|
||||
}
|
||||
|
||||
@@ -14,7 +16,6 @@ interface MessageListProps {
|
||||
* Extracts <thinking>...</thinking> or <think>...</think> blocks.
|
||||
*/
|
||||
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 matches = content.match(thinkingRegex);
|
||||
|
||||
@@ -22,14 +23,12 @@ function parseThinking(content: string): { thinking: string | null; response: st
|
||||
return { thinking: null, response: content };
|
||||
}
|
||||
|
||||
// Extract thinking content
|
||||
let thinking = "";
|
||||
for (const match of matches) {
|
||||
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
|
||||
thinking += innerContent.trim() + "\n";
|
||||
}
|
||||
|
||||
// Remove thinking blocks from response
|
||||
const response = content.replace(thinkingRegex, "").trim();
|
||||
|
||||
const trimmedThinking = thinking.trim();
|
||||
@@ -42,25 +41,47 @@ function parseThinking(content: string): { thinking: string | null; response: st
|
||||
export function MessageList({
|
||||
messages,
|
||||
isLoading,
|
||||
isStreaming = false,
|
||||
streamingMessageId,
|
||||
loadingQuip,
|
||||
}: 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 (
|
||||
<div className="space-y-6" role="log" aria-label="Chat messages">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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 [copied, setCopied] = useState(false);
|
||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||
|
||||
// Parse thinking from content (or use pre-parsed thinking field)
|
||||
const { thinking, response } = message.thinking
|
||||
? { thinking: message.thinking, response: message.content }
|
||||
: parseThinking(message.content);
|
||||
@@ -73,7 +94,6 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
// Silently fail - clipboard copy is non-critical
|
||||
void err;
|
||||
}
|
||||
}, [response]);
|
||||
@@ -106,8 +126,21 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
||||
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||
{isUser ? "You" : "AI Assistant"}
|
||||
</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 */}
|
||||
{!isUser && message.model && (
|
||||
{!isUser && message.model && !isStreaming && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
style={{
|
||||
@@ -200,43 +233,54 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
||||
border: isUser ? "none" : "1px solid rgb(var(--border-default))",
|
||||
}}
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{response}</p>
|
||||
|
||||
{/* Copy Button - appears on hover */}
|
||||
<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>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{response}
|
||||
{/* Blinking cursor during streaming */}
|
||||
{isStreaming && !isUser && (
|
||||
<span
|
||||
className="streaming-cursor inline-block ml-0.5 align-middle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -27,6 +27,7 @@ interface AgentNode {
|
||||
name: string;
|
||||
task: string;
|
||||
status: DotVariant;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
interface OrchestratorSession {
|
||||
@@ -36,6 +37,7 @@ interface OrchestratorSession {
|
||||
badge: string;
|
||||
badgeVariant: BadgeVariant;
|
||||
duration: string;
|
||||
progress: number;
|
||||
agents: AgentNode[];
|
||||
}
|
||||
|
||||
@@ -105,6 +107,7 @@ function mapJobToSession(job: ActiveJob): OrchestratorSession {
|
||||
name: step.name,
|
||||
task: `Phase: ${step.phase}`,
|
||||
status: statusToDotVariant(step.status),
|
||||
statusLabel: step.status.toLowerCase(),
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -114,6 +117,7 @@ function mapJobToSession(job: ActiveJob): OrchestratorSession {
|
||||
badge: job.status,
|
||||
badgeVariant: statusToBadgeVariant(job.status),
|
||||
duration: formatDuration(job.createdAt),
|
||||
progress: job.progressPercent,
|
||||
agents,
|
||||
};
|
||||
}
|
||||
@@ -192,6 +196,16 @@ function AgentNodeItem({ agent }: AgentNodeItemProps): ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<Dot variant={agent.status} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.65rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--muted)",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{agent.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -251,6 +265,27 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
||||
{session.duration}
|
||||
</span>
|
||||
</div>
|
||||
{session.progress > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background: "var(--border)",
|
||||
marginBottom: 10,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${String(session.progress)}%`,
|
||||
background: "var(--primary)",
|
||||
borderRadius: 2,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{session.agents.map((agent) => (
|
||||
<AgentNodeItem key={agent.id} agent={agent} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef } from "react";
|
||||
import { LinkAutocomplete } from "./LinkAutocomplete";
|
||||
import React from "react";
|
||||
import { KnowledgeEditor } from "./KnowledgeEditor";
|
||||
|
||||
interface EntryEditorProps {
|
||||
content: string;
|
||||
@@ -9,57 +9,21 @@ interface EntryEditorProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* EntryEditor - Markdown editor with live preview and link autocomplete
|
||||
* EntryEditor - WYSIWYG editor for knowledge entries.
|
||||
* Wraps KnowledgeEditor (Tiptap) with markdown round-trip.
|
||||
* Content is stored as markdown; the editor provides rich text editing.
|
||||
*/
|
||||
export function EntryEditor({ content, onChange }: EntryEditorProps): React.JSX.Element {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
return (
|
||||
<div className="entry-editor relative">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Content (Markdown)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowPreview(!showPreview);
|
||||
}}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{showPreview ? "Edit" : "Preview"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showPreview ? (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 min-h-[300px]">
|
||||
<div className="whitespace-pre-wrap">{content}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
className="w-full min-h-[300px] p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Write your content here... (Markdown supported)"
|
||||
/>
|
||||
<LinkAutocomplete
|
||||
textareaRef={textareaRef}
|
||||
onInsert={(newContent) => {
|
||||
onChange(newContent);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Supports Markdown formatting. Type <code className="text-xs">[[</code> to insert links to
|
||||
other entries.
|
||||
</p>
|
||||
<div className="entry-editor">
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: "var(--text-2)" }}>
|
||||
Content
|
||||
</label>
|
||||
<KnowledgeEditor
|
||||
content={content}
|
||||
onChange={onChange}
|
||||
placeholder="Write your content here... Supports markdown formatting."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
||||
import { EntryCard } from "./EntryCard";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||
|
||||
interface EntryListProps {
|
||||
entries: KnowledgeEntryWithTags[];
|
||||
@@ -20,18 +21,22 @@ export function EntryList({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span className="ml-3 text-gray-600">Loading entries...</span>
|
||||
<MosaicSpinner size={36} label="Loading entries..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-12 bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<BookOpen className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-lg text-gray-700 font-medium">No entries found</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
<div
|
||||
className="text-center p-12 rounded-lg border"
|
||||
style={{ background: "var(--surface)", borderColor: "var(--border)" }}
|
||||
>
|
||||
<BookOpen className="w-12 h-12 mx-auto mb-3" style={{ color: "var(--text-muted)" }} />
|
||||
<p className="text-lg font-medium" style={{ color: "var(--text-muted)" }}>
|
||||
No entries found
|
||||
</p>
|
||||
<p className="text-sm mt-2" style={{ color: "var(--text-muted)" }}>
|
||||
Try adjusting your filters or create a new entry
|
||||
</p>
|
||||
</div>
|
||||
|
||||
245
apps/web/src/components/knowledge/KnowledgeEditor.css
Normal file
245
apps/web/src/components/knowledge/KnowledgeEditor.css
Normal file
@@ -0,0 +1,245 @@
|
||||
/* KnowledgeEditor — Tiptap/ProseMirror styles
|
||||
Uses CSS variables for theme compatibility */
|
||||
|
||||
.knowledge-editor-content .tiptap {
|
||||
min-height: 300px;
|
||||
padding: 16px 20px;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.knowledge-editor-content .tiptap p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: var(--muted);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.knowledge-editor-content .tiptap h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 1.5em 0 0.5em;
|
||||
color: var(--text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap h2 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
margin: 1.3em 0 0.4em;
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 1.2em 0 0.3em;
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap h1:first-child,
|
||||
.knowledge-editor-content .tiptap h2:first-child,
|
||||
.knowledge-editor-content .tiptap h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
.knowledge-editor-content .tiptap p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
/* Bold / Italic / Strikethrough */
|
||||
.knowledge-editor-content .tiptap strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap s {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.knowledge-editor-content .tiptap code {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 1px 5px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--primary-l);
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.knowledge-editor-content .tiptap pre {
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
padding: 12px 16px;
|
||||
margin: 0.75em 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.knowledge-editor-content .tiptap ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap li {
|
||||
margin: 0.2em 0;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap li > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Blockquote */
|
||||
.knowledge-editor-content .tiptap blockquote {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding: 4px 16px;
|
||||
margin: 0.75em 0;
|
||||
color: var(--text-2);
|
||||
background: var(--surface-2);
|
||||
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap blockquote p {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
/* Horizontal rule */
|
||||
.knowledge-editor-content .tiptap hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.knowledge-editor-content .tiptap a,
|
||||
.knowledge-editor-content .tiptap .knowledge-editor-link {
|
||||
color: var(--primary-l);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.knowledge-editor-content .tiptap table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.75em 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap th,
|
||||
.knowledge-editor-content .tiptap td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap th {
|
||||
background: var(--surface-2);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap td {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* Table selected cell highlight */
|
||||
.knowledge-editor-content .tiptap .selectedCell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--primary);
|
||||
opacity: 0.08;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap th.selectedCell::after,
|
||||
.knowledge-editor-content .tiptap td.selectedCell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--primary);
|
||||
opacity: 0.08;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Table cell relative positioning for selection overlay */
|
||||
.knowledge-editor-content .tiptap th,
|
||||
.knowledge-editor-content .tiptap td {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Column resize handle */
|
||||
.knowledge-editor-content .tiptap .column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: var(--primary);
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.knowledge-editor-content .tiptap .tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Syntax highlighting tokens (lowlight/highlight.js) */
|
||||
.knowledge-editor-content .tiptap pre .hljs-keyword { color: var(--ms-purple-400); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-string { color: var(--ms-teal-400); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-number { color: var(--ms-amber-400); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-comment { color: var(--muted); font-style: italic; }
|
||||
.knowledge-editor-content .tiptap pre .hljs-function { color: var(--ms-blue-400); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-title { color: var(--ms-blue-400); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-params { color: var(--text-2); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-built_in { color: var(--ms-cyan-500); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-literal { color: var(--ms-amber-400); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-type { color: var(--ms-teal-400); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-attr { color: var(--ms-purple-400); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-selector-class { color: var(--ms-blue-400); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-selector-tag { color: var(--ms-red-400); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-variable { color: var(--text); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-meta { color: var(--muted); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-tag { color: var(--ms-red-400); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-name { color: var(--ms-red-400); }
|
||||
.knowledge-editor-content .tiptap pre .hljs-attribute { color: var(--ms-purple-400); }
|
||||
450
apps/web/src/components/knowledge/KnowledgeEditor.tsx
Normal file
450
apps/web/src/components/knowledge/KnowledgeEditor.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import { TableRow } from "@tiptap/extension-table-row";
|
||||
import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { TableHeader } from "@tiptap/extension-table-header";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { MarkdownStorage } from "tiptap-markdown";
|
||||
|
||||
import "./KnowledgeEditor.css";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
export interface KnowledgeEditorProps {
|
||||
/** Markdown content for the editor */
|
||||
content: string;
|
||||
/** Called when editor content changes (provides markdown) */
|
||||
onChange: (markdown: string) => void;
|
||||
/** Placeholder text when editor is empty */
|
||||
placeholder?: string;
|
||||
/** Whether the editor is editable */
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
/** Toolbar button helper */
|
||||
function ToolbarButton({
|
||||
onClick,
|
||||
active,
|
||||
disabled,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "var(--r-sm)",
|
||||
border: "none",
|
||||
background: active ? "var(--primary)" : "transparent",
|
||||
color: active ? "#fff" : "var(--text-2)",
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
fontSize: "0.82rem",
|
||||
fontWeight: 600,
|
||||
transition: "all 0.12s ease",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Separator between toolbar groups */
|
||||
function ToolbarSep(): ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 1,
|
||||
height: 20,
|
||||
background: "var(--border)",
|
||||
margin: "0 4px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Link insertion handler — prompts for URL */
|
||||
function toggleLink(editor: Editor): void {
|
||||
if (editor.isActive("link")) {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
return;
|
||||
}
|
||||
const url = window.prompt("Enter URL:");
|
||||
if (url) {
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
||||
}
|
||||
}
|
||||
|
||||
/* SVG icon components for toolbar */
|
||||
function BulletListIcon(): ReactElement {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<circle cx="3" cy="4" r="1.5" />
|
||||
<rect x="6" y="3" width="8" height="2" rx="0.5" />
|
||||
<circle cx="3" cy="8" r="1.5" />
|
||||
<rect x="6" y="7" width="8" height="2" rx="0.5" />
|
||||
<circle cx="3" cy="12" r="1.5" />
|
||||
<rect x="6" y="11" width="8" height="2" rx="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderedListIcon(): ReactElement {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<text x="1" y="5.5" fontSize="5" fontWeight="bold">
|
||||
1
|
||||
</text>
|
||||
<rect x="6" y="3" width="8" height="2" rx="0.5" />
|
||||
<text x="1" y="9.5" fontSize="5" fontWeight="bold">
|
||||
2
|
||||
</text>
|
||||
<rect x="6" y="7" width="8" height="2" rx="0.5" />
|
||||
<text x="1" y="13.5" fontSize="5" fontWeight="bold">
|
||||
3
|
||||
</text>
|
||||
<rect x="6" y="11" width="8" height="2" rx="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function QuoteIcon(): ReactElement {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M3 3h2l-1 4h2v6H2V7l1-4zm7 0h2l-1 4h2v6H9V7l1-4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlockIcon(): ReactElement {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<polyline points="5,3 1,8 5,13" />
|
||||
<polyline points="11,3 15,8 11,13" />
|
||||
<line x1="9" y1="2" x2="7" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkIcon(): ReactElement {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M6.5 9.5l3-3" />
|
||||
<path d="M9 6l1.5-1.5a2.12 2.12 0 013 3L12 9" />
|
||||
<path d="M7 10l-1.5 1.5a2.12 2.12 0 01-3-3L4 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TableIcon(): ReactElement {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
>
|
||||
<rect x="1" y="2" width="14" height="12" rx="1" />
|
||||
<line x1="1" y1="6" x2="15" y2="6" />
|
||||
<line x1="1" y1="10" x2="15" y2="10" />
|
||||
<line x1="6" y1="2" x2="6" y2="14" />
|
||||
<line x1="11" y1="2" x2="11" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Editor toolbar component */
|
||||
function EditorToolbar({ editor }: { editor: Editor }): ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
padding: "6px 8px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
background: "var(--surface-2)",
|
||||
borderRadius: "var(--r-lg) var(--r-lg) 0 0",
|
||||
}}
|
||||
>
|
||||
{/* Headings */}
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run();
|
||||
}}
|
||||
active={editor.isActive("heading", { level: 1 })}
|
||||
title="Heading 1"
|
||||
>
|
||||
H1
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||
}}
|
||||
active={editor.isActive("heading", { level: 2 })}
|
||||
title="Heading 2"
|
||||
>
|
||||
H2
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run();
|
||||
}}
|
||||
active={editor.isActive("heading", { level: 3 })}
|
||||
title="Heading 3"
|
||||
>
|
||||
H3
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSep />
|
||||
|
||||
{/* Text formatting */}
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
editor.chain().focus().toggleBold().run();
|
||||
}}
|
||||
active={editor.isActive("bold")}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
B
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
editor.chain().focus().toggleItalic().run();
|
||||
}}
|
||||
active={editor.isActive("italic")}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<span style={{ fontStyle: "italic" }}>I</span>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
editor.chain().focus().toggleStrike().run();
|
||||
}}
|
||||
active={editor.isActive("strike")}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<span style={{ textDecoration: "line-through" }}>S</span>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
editor.chain().focus().toggleCode().run();
|
||||
}}
|
||||
active={editor.isActive("code")}
|
||||
title="Inline Code"
|
||||
>
|
||||
{"<>"}
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSep />
|
||||
|
||||
{/* Lists */}
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
editor.chain().focus().toggleBulletList().run();
|
||||
}}
|
||||
active={editor.isActive("bulletList")}
|
||||
title="Bullet List"
|
||||
>
|
||||
<BulletListIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
editor.chain().focus().toggleOrderedList().run();
|
||||
}}
|
||||
active={editor.isActive("orderedList")}
|
||||
title="Ordered List"
|
||||
>
|
||||
<OrderedListIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
editor.chain().focus().toggleBlockquote().run();
|
||||
}}
|
||||
active={editor.isActive("blockquote")}
|
||||
title="Blockquote"
|
||||
>
|
||||
<QuoteIcon />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSep />
|
||||
|
||||
{/* Code block */}
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
}}
|
||||
active={editor.isActive("codeBlock")}
|
||||
title="Code Block"
|
||||
>
|
||||
<CodeBlockIcon />
|
||||
</ToolbarButton>
|
||||
|
||||
{/* Link */}
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
toggleLink(editor);
|
||||
}}
|
||||
active={editor.isActive("link")}
|
||||
title="Insert Link"
|
||||
>
|
||||
<LinkIcon />
|
||||
</ToolbarButton>
|
||||
|
||||
{/* Table */}
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
}}
|
||||
disabled={editor.isActive("table")}
|
||||
title="Insert Table"
|
||||
>
|
||||
<TableIcon />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSep />
|
||||
|
||||
{/* Horizontal rule */}
|
||||
<ToolbarButton
|
||||
onClick={(): void => {
|
||||
editor.chain().focus().setHorizontalRule().run();
|
||||
}}
|
||||
title="Horizontal Rule"
|
||||
>
|
||||
—
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KnowledgeEditor({
|
||||
content,
|
||||
onChange,
|
||||
placeholder = "Start writing...",
|
||||
editable = true,
|
||||
}: KnowledgeEditorProps): ReactElement {
|
||||
const handleUpdate = useCallback(
|
||||
({ editor: e }: { editor: Editor }) => {
|
||||
const s = e.storage as unknown as Record<string, MarkdownStorage>;
|
||||
const mdStorage = s.markdown;
|
||||
if (mdStorage) {
|
||||
onChange(mdStorage.getMarkdown());
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
rel: "noopener noreferrer",
|
||||
class: "knowledge-editor-link",
|
||||
},
|
||||
}),
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
breaks: false,
|
||||
tightLists: true,
|
||||
transformPastedText: true,
|
||||
transformCopiedText: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
editable,
|
||||
onUpdate: handleUpdate,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- useEditor returns null during SSR/init
|
||||
if (!editor) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: 300,
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--muted)", fontSize: "0.85rem" }}>Loading editor...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="knowledge-editor"
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
overflow: "hidden",
|
||||
background: "var(--surface)",
|
||||
}}
|
||||
>
|
||||
{editable && <EditorToolbar editor={editor} />}
|
||||
<EditorContent editor={editor} className="knowledge-editor-content" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,30 @@ import * as knowledgeApi from "@/lib/api/knowledge";
|
||||
// Mock the knowledge API
|
||||
vi.mock("@/lib/api/knowledge");
|
||||
|
||||
// Mock MosaicSpinner to expose a test ID
|
||||
vi.mock("@/components/ui/MosaicSpinner", () => ({
|
||||
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
|
||||
<div data-testid="loading-spinner">{label ?? "Loading..."}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock elkjs since it requires APIs not available in test environment
|
||||
vi.mock("elkjs/lib/elk.bundled.js", () => ({
|
||||
default: class ELK {
|
||||
layout(graph: {
|
||||
children?: { id: string }[];
|
||||
}): Promise<{ children: { id: string; x: number; y: number }[] }> {
|
||||
return Promise.resolve({
|
||||
children: (graph.children ?? []).map((child: { id: string }, i: number) => ({
|
||||
id: child.id,
|
||||
x: i * 100,
|
||||
y: i * 100,
|
||||
})),
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Next.js router
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { fetchKnowledgeGraph } from "@/lib/api/knowledge";
|
||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||
import ELK from "elkjs/lib/elk.bundled.js";
|
||||
|
||||
// PDA-friendly status colors from CLAUDE.md
|
||||
@@ -376,10 +377,7 @@ export function KnowledgeGraphViewer({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div
|
||||
data-testid="loading-spinner"
|
||||
className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"
|
||||
/>
|
||||
<MosaicSpinner size={48} label="Loading knowledge graph..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -387,11 +385,14 @@ export function KnowledgeGraphViewer({
|
||||
if (error || !graphData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen p-8">
|
||||
<div className="text-red-500 text-xl font-semibold mb-2">Error Loading Graph</div>
|
||||
<div className="text-xl font-semibold mb-2" style={{ color: "var(--danger)" }}>
|
||||
Error Loading Graph
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{error}</div>
|
||||
<button
|
||||
onClick={loadGraph}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
className="mt-4 px-4 py-2 rounded text-white"
|
||||
style={{ background: "var(--danger)" }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchKnowledgeStats } from "@/lib/api/knowledge";
|
||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||
import Link from "next/link";
|
||||
|
||||
interface KnowledgeStats {
|
||||
@@ -61,13 +62,20 @@ export function StatsDashboard(): React.JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||
<MosaicSpinner size={36} label="Loading statistics..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return <div className="p-8 text-center text-red-500">Error loading statistics: {error}</div>;
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="font-medium mb-2" style={{ color: "var(--danger)" }}>
|
||||
Error loading statistics
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { overview, mostConnected, recentActivity, tagDistribution } = stats;
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { EntryEditor } from "../EntryEditor";
|
||||
|
||||
// Mock the LinkAutocomplete component
|
||||
vi.mock("../LinkAutocomplete", () => ({
|
||||
LinkAutocomplete: (): React.JSX.Element => (
|
||||
<div data-testid="link-autocomplete">LinkAutocomplete</div>
|
||||
// Mock KnowledgeEditor since Tiptap requires a full DOM
|
||||
vi.mock("../KnowledgeEditor", () => ({
|
||||
KnowledgeEditor: ({
|
||||
content,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
content: string;
|
||||
onChange: (md: string) => void;
|
||||
placeholder?: string;
|
||||
}): React.JSX.Element => (
|
||||
<div data-testid="knowledge-editor" data-content={content} data-placeholder={placeholder}>
|
||||
<button
|
||||
data-testid="trigger-change"
|
||||
onClick={(): void => {
|
||||
onChange("updated content");
|
||||
}}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -21,133 +37,50 @@ describe("EntryEditor", (): void => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render textarea in edit mode by default", (): void => {
|
||||
it("should render KnowledgeEditor component", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
||||
expect(textarea).toBeInTheDocument();
|
||||
expect(textarea.tagName).toBe("TEXTAREA");
|
||||
expect(screen.getByTestId("knowledge-editor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display current content in textarea", (): void => {
|
||||
it("should have a content label", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pass content to KnowledgeEditor", (): void => {
|
||||
const content = "# Test Content\n\nThis is a test.";
|
||||
render(<EntryEditor {...defaultProps} content={content} />);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const textarea = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe(content);
|
||||
const editor = screen.getByTestId("knowledge-editor");
|
||||
expect(editor).toHaveAttribute("data-content", content);
|
||||
});
|
||||
|
||||
it("should call onChange when content is modified", async (): Promise<void> => {
|
||||
it("should pass placeholder to KnowledgeEditor", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
const editor = screen.getByTestId("knowledge-editor");
|
||||
expect(editor).toHaveAttribute(
|
||||
"data-placeholder",
|
||||
"Write your content here... Supports markdown formatting."
|
||||
);
|
||||
});
|
||||
|
||||
it("should forward onChange to KnowledgeEditor", async (): Promise<void> => {
|
||||
const { default: userEvent } = await import("@testing-library/user-event");
|
||||
const user = userEvent.setup();
|
||||
const onChangeMock = vi.fn();
|
||||
|
||||
render(<EntryEditor {...defaultProps} onChange={onChangeMock} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
||||
await user.type(textarea, "Hello");
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalled();
|
||||
await user.click(screen.getByTestId("trigger-change"));
|
||||
expect(onChangeMock).toHaveBeenCalledWith("updated content");
|
||||
});
|
||||
|
||||
it("should toggle between edit and preview modes", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const content = "# Test\n\nPreview this content.";
|
||||
it("should render with entry-editor wrapper class", (): void => {
|
||||
const { container } = render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
render(<EntryEditor {...defaultProps} content={content} />);
|
||||
|
||||
// Initially in edit mode
|
||||
expect(screen.getByPlaceholderText(/Write your content here/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Preview")).toBeInTheDocument();
|
||||
|
||||
// Switch to preview mode
|
||||
const previewButton = screen.getByText("Preview");
|
||||
await user.click(previewButton);
|
||||
|
||||
// Should show preview
|
||||
expect(screen.queryByPlaceholderText(/Write your content here/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Edit")).toBeInTheDocument();
|
||||
// Check for partial content (newlines may split text across elements)
|
||||
expect(screen.getByText(/Test/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Preview this content/)).toBeInTheDocument();
|
||||
|
||||
// Switch back to edit mode
|
||||
const editButton = screen.getByText("Edit");
|
||||
await user.click(editButton);
|
||||
|
||||
// Should show textarea again
|
||||
expect(screen.getByPlaceholderText(/Write your content here/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render LinkAutocomplete component in edit mode", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("link-autocomplete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render LinkAutocomplete in preview mode", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
// LinkAutocomplete should be present in edit mode
|
||||
expect(screen.getByTestId("link-autocomplete")).toBeInTheDocument();
|
||||
|
||||
// Switch to preview mode
|
||||
const previewButton = screen.getByText("Preview");
|
||||
await user.click(previewButton);
|
||||
|
||||
// LinkAutocomplete should not be in preview mode
|
||||
expect(screen.queryByTestId("link-autocomplete")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show help text about wiki-link syntax", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/Type/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\[\[/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/to insert links/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maintain content when toggling between modes", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const content = "# My Content\n\nThis should persist.";
|
||||
|
||||
render(<EntryEditor {...defaultProps} content={content} />);
|
||||
|
||||
// Verify content in edit mode
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const textarea = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe(content);
|
||||
|
||||
// Toggle to preview
|
||||
await user.click(screen.getByText("Preview"));
|
||||
// Check for partial content (newlines may split text across elements)
|
||||
expect(screen.getByText(/My Content/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/This should persist/)).toBeInTheDocument();
|
||||
|
||||
// Toggle back to edit
|
||||
await user.click(screen.getByText("Edit"));
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const textareaAfter = screen.getByPlaceholderText(
|
||||
/Write your content here/
|
||||
) as HTMLTextAreaElement;
|
||||
expect(textareaAfter.value).toBe(content);
|
||||
});
|
||||
|
||||
it("should apply correct styling classes", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
||||
expect(textarea).toHaveClass("font-mono");
|
||||
expect(textarea).toHaveClass("text-sm");
|
||||
expect(textarea).toHaveClass("min-h-[300px]");
|
||||
});
|
||||
|
||||
it("should have label for content field", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Content (Markdown)")).toBeInTheDocument();
|
||||
expect(container.querySelector(".entry-editor")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -37,16 +37,31 @@ export function BaseWidget({
|
||||
return (
|
||||
<div
|
||||
data-widget-id={id}
|
||||
className={cn(
|
||||
"flex flex-col h-full bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden",
|
||||
className
|
||||
)}
|
||||
className={cn("flex flex-col h-full overflow-hidden", className)}
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-sm)",
|
||||
}}
|
||||
>
|
||||
{/* Widget Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50">
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border)",
|
||||
background: "var(--surface-2)",
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
|
||||
{description && <p className="text-xs text-gray-500 truncate mt-0.5">{description}</p>}
|
||||
<h3 className="text-sm font-semibold truncate" style={{ color: "var(--text)" }}>
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-xs truncate mt-0.5" style={{ color: "var(--muted)" }}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control buttons - only show if handlers provided */}
|
||||
@@ -56,7 +71,8 @@ export function BaseWidget({
|
||||
<button
|
||||
onClick={onEdit}
|
||||
aria-label="Edit widget"
|
||||
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
||||
className="p-1 rounded transition-colors"
|
||||
style={{ color: "var(--muted)" }}
|
||||
title="Edit widget"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
@@ -66,7 +82,8 @@ export function BaseWidget({
|
||||
<button
|
||||
onClick={onRemove}
|
||||
aria-label="Remove widget"
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
className="p-1 rounded transition-colors"
|
||||
style={{ color: "var(--muted)" }}
|
||||
title="Remove widget"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -81,15 +98,24 @@ export function BaseWidget({
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-gray-500">Loading...</span>
|
||||
<div
|
||||
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: "var(--muted)" }}>
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 text-sm font-medium mb-1">Error</div>
|
||||
<div className="text-xs text-gray-600">{error}</div>
|
||||
<div className="text-sm font-medium mb-1" style={{ color: "var(--danger)" }}>
|
||||
Error
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: "var(--muted)" }}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
183
apps/web/src/components/widgets/WidgetConfigDialog.tsx
Normal file
183
apps/web/src/components/widgets/WidgetConfigDialog.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* WidgetConfigDialog — Per-widget settings dialog.
|
||||
*
|
||||
* Reads configSchema from the widget definition. When the schema is empty
|
||||
* (current state for all 7 widgets), shows a placeholder message.
|
||||
* As widgets gain configSchema definitions, this dialog will render
|
||||
* appropriate form controls.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { getWidgetByName } from "./WidgetRegistry";
|
||||
|
||||
export interface WidgetConfigDialogProps {
|
||||
widgetId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WidgetConfigDialog({
|
||||
widgetId,
|
||||
open,
|
||||
onClose,
|
||||
}: WidgetConfigDialogProps): ReactElement | null {
|
||||
const [hoverClose, setHoverClose] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Extract widget type from ID (format: "WidgetType-suffix")
|
||||
const widgetType = widgetId.split("-")[0] ?? "";
|
||||
const widgetDef = getWidgetByName(widgetType);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
zIndex: 999,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Widget Settings"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 420,
|
||||
maxWidth: "90vw",
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
zIndex: 1000,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "16px 20px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{widgetDef?.displayName ?? "Widget"} Settings
|
||||
</h2>
|
||||
{widgetDef?.description && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.78rem",
|
||||
color: "var(--muted)",
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{widgetDef.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
padding: 4,
|
||||
fontSize: "1.2rem",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: "20px" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "24px 16px",
|
||||
textAlign: "center",
|
||||
borderRadius: "var(--r)",
|
||||
background: "var(--surface-2)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
color: "var(--muted)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
No configuration options available for this widget yet.
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.78rem",
|
||||
color: "var(--muted)",
|
||||
margin: "8px 0 0",
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
Widget configuration will be added in a future update.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
padding: "12px 20px",
|
||||
borderTop: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
onMouseEnter={(): void => {
|
||||
setHoverClose(true);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setHoverClose(false);
|
||||
}}
|
||||
style={{
|
||||
padding: "6px 16px",
|
||||
borderRadius: "var(--r)",
|
||||
border: "1px solid var(--border)",
|
||||
background: hoverClose ? "var(--surface-2)" : "transparent",
|
||||
color: "var(--text-2)",
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
transition: "background 0.12s ease",
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||
import GridLayout from "react-grid-layout";
|
||||
import type { Layout, LayoutItem } from "react-grid-layout";
|
||||
import type { WidgetPlacement } from "@mosaic/shared";
|
||||
@@ -22,6 +22,7 @@ export interface WidgetGridProps {
|
||||
layout: WidgetPlacement[];
|
||||
onLayoutChange: (layout: WidgetPlacement[]) => void;
|
||||
onRemoveWidget?: (widgetId: string) => void;
|
||||
onEditWidget?: (widgetId: string) => void;
|
||||
isEditing?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -30,9 +31,34 @@ export function WidgetGrid({
|
||||
layout,
|
||||
onLayoutChange,
|
||||
onRemoveWidget,
|
||||
onEditWidget,
|
||||
isEditing = false,
|
||||
className,
|
||||
}: WidgetGridProps): React.JSX.Element {
|
||||
// Measure container width for responsive grid
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(1200);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver((entries): void => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
|
||||
// Set initial width
|
||||
setContainerWidth(el.clientWidth);
|
||||
|
||||
return (): void => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Convert WidgetPlacement to react-grid-layout Layout format
|
||||
const gridLayout: Layout = useMemo(
|
||||
() =>
|
||||
@@ -96,22 +122,34 @@ export function WidgetGrid({
|
||||
// Empty state
|
||||
if (layout.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full min-h-[400px] bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex items-center justify-center h-full min-h-[400px]"
|
||||
style={{
|
||||
background: "var(--surface-2)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
border: "2px dashed var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 text-lg font-medium">No widgets yet</p>
|
||||
<p className="text-gray-400 text-sm mt-1">Add widgets to customize your dashboard</p>
|
||||
<p className="text-lg font-medium" style={{ color: "var(--muted)" }}>
|
||||
No widgets yet
|
||||
</p>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--muted)", opacity: 0.7 }}>
|
||||
Add widgets to customize your dashboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("widget-grid-container", className)}>
|
||||
<div ref={containerRef} className={cn("widget-grid-container", className)}>
|
||||
<GridLayout
|
||||
className="layout"
|
||||
layout={gridLayout}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
width={1200}
|
||||
width={containerWidth}
|
||||
gridConfig={{
|
||||
cols: 12,
|
||||
rowHeight: 100,
|
||||
@@ -147,6 +185,12 @@ export function WidgetGrid({
|
||||
id={item.i}
|
||||
title={widgetDef.displayName}
|
||||
description={widgetDef.description}
|
||||
{...(isEditing &&
|
||||
onEditWidget && {
|
||||
onEdit: (): void => {
|
||||
onEditWidget(item.i);
|
||||
},
|
||||
})}
|
||||
{...(isEditing &&
|
||||
onRemoveWidget && {
|
||||
onRemove: (): void => {
|
||||
|
||||
277
apps/web/src/components/widgets/WidgetPicker.tsx
Normal file
277
apps/web/src/components/widgets/WidgetPicker.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* WidgetPicker — Dialog to browse available widgets and add them to the dashboard.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import type { WidgetPlacement } from "@mosaic/shared";
|
||||
import { getAllWidgets, type WidgetDefinition } from "./WidgetRegistry";
|
||||
|
||||
export interface WidgetPickerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAddWidget: (placement: WidgetPlacement) => void;
|
||||
currentLayout: WidgetPlacement[];
|
||||
}
|
||||
|
||||
/** Generate a unique widget ID: "WidgetType-<random>" */
|
||||
function generateWidgetId(widgetName: string): string {
|
||||
const suffix = Math.random().toString(36).slice(2, 8);
|
||||
return `${widgetName}-${suffix}`;
|
||||
}
|
||||
|
||||
/** Find the first open Y position at x=0 that doesn't overlap */
|
||||
function findNextY(layout: WidgetPlacement[]): number {
|
||||
if (layout.length === 0) return 0;
|
||||
let maxBottom = 0;
|
||||
for (const item of layout) {
|
||||
const bottom = item.y + item.h;
|
||||
if (bottom > maxBottom) maxBottom = bottom;
|
||||
}
|
||||
return maxBottom;
|
||||
}
|
||||
|
||||
function WidgetPickerItem({
|
||||
widget,
|
||||
onAdd,
|
||||
}: {
|
||||
widget: WidgetDefinition;
|
||||
onAdd: () => void;
|
||||
}): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={(): void => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "12px 16px",
|
||||
borderRadius: "var(--r)",
|
||||
background: hovered ? "var(--surface-2)" : "transparent",
|
||||
transition: "background 0.12s ease",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{widget.displayName}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.78rem",
|
||||
color: "var(--muted)",
|
||||
marginTop: 2,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{widget.description}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
color: "var(--muted)",
|
||||
marginTop: 4,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
Default size: {widget.defaultWidth}×{widget.defaultHeight}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAdd}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "var(--r)",
|
||||
border: "1px solid var(--primary)",
|
||||
background: hovered ? "var(--primary)" : "transparent",
|
||||
color: hovered ? "#fff" : "var(--primary-l)",
|
||||
fontSize: "0.78rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.12s ease",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetPicker({
|
||||
open,
|
||||
onClose,
|
||||
onAddWidget,
|
||||
currentLayout,
|
||||
}: WidgetPickerProps): ReactElement | null {
|
||||
const allWidgets = getAllWidgets();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = search
|
||||
? allWidgets.filter(
|
||||
(w) =>
|
||||
w.displayName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
w.description.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: allWidgets;
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(widget: WidgetDefinition) => {
|
||||
const placement: WidgetPlacement = {
|
||||
i: generateWidgetId(widget.name),
|
||||
x: 0,
|
||||
y: findNextY(currentLayout),
|
||||
w: widget.defaultWidth,
|
||||
h: widget.defaultHeight,
|
||||
minW: widget.minWidth,
|
||||
minH: widget.minHeight,
|
||||
};
|
||||
if (widget.maxWidth !== undefined) placement.maxW = widget.maxWidth;
|
||||
if (widget.maxHeight !== undefined) placement.maxH = widget.maxHeight;
|
||||
onAddWidget(placement);
|
||||
},
|
||||
[currentLayout, onAddWidget]
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
zIndex: 999,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Add Widget"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 380,
|
||||
maxWidth: "90vw",
|
||||
background: "var(--surface)",
|
||||
borderLeft: "1px solid var(--border)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
zIndex: 1000,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "16px 20px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.1rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Add Widget
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
padding: 4,
|
||||
fontSize: "1.2rem",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ padding: "12px 20px 0" }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search widgets..."
|
||||
value={search}
|
||||
onChange={(e): void => {
|
||||
setSearch(e.target.value);
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "var(--r)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--surface-2)",
|
||||
color: "var(--text)",
|
||||
fontSize: "0.85rem",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Widget list */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 20,
|
||||
textAlign: "center",
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
No widgets found
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((widget) => (
|
||||
<WidgetPickerItem
|
||||
key={widget.name}
|
||||
widget={widget}
|
||||
onAdd={(): void => {
|
||||
handleAdd(widget);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,20 @@
|
||||
* Following TDD - write tests first!
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { WidgetGrid } from "../WidgetGrid";
|
||||
import type { WidgetPlacement } from "@mosaic/shared";
|
||||
|
||||
// ResizeObserver is not available in jsdom
|
||||
beforeAll((): void => {
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
// Mock react-grid-layout
|
||||
vi.mock("react-grid-layout", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
||||
|
||||
101
apps/web/src/components/widgets/__tests__/WidgetPicker.test.tsx
Normal file
101
apps/web/src/components/widgets/__tests__/WidgetPicker.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { WidgetPicker } from "../WidgetPicker";
|
||||
import type { WidgetPlacement } from "@mosaic/shared";
|
||||
|
||||
describe("WidgetPicker", (): void => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onAddWidget: vi.fn(),
|
||||
currentLayout: [] as WidgetPlacement[],
|
||||
};
|
||||
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render nothing when closed", (): void => {
|
||||
const { container } = render(<WidgetPicker {...defaultProps} open={false} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("should render dialog when open", (): void => {
|
||||
render(<WidgetPicker {...defaultProps} />);
|
||||
expect(screen.getByRole("dialog", { name: "Add Widget" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Add Widget heading", (): void => {
|
||||
render(<WidgetPicker {...defaultProps} />);
|
||||
expect(screen.getByText("Add Widget")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render search input", (): void => {
|
||||
render(<WidgetPicker {...defaultProps} />);
|
||||
expect(screen.getByPlaceholderText("Search widgets...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display available widgets", (): void => {
|
||||
render(<WidgetPicker {...defaultProps} />);
|
||||
// Widget registry has multiple widgets; at least one Add button should appear
|
||||
const addButtons = screen.getAllByText("Add");
|
||||
expect(addButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should filter widgets by search text", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(<WidgetPicker {...defaultProps} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText("Search widgets...");
|
||||
// Type a search term that won't match any widget
|
||||
await user.type(searchInput, "zzz-nonexistent-widget-zzz");
|
||||
|
||||
expect(screen.getByText("No widgets found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onAddWidget when Add is clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const onAdd = vi.fn();
|
||||
render(<WidgetPicker {...defaultProps} onAddWidget={onAdd} />);
|
||||
|
||||
const addButtons = screen.getAllByText("Add");
|
||||
await user.click(addButtons[0]!);
|
||||
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
const placement = onAdd.mock.calls[0]![0] as WidgetPlacement;
|
||||
expect(placement).toHaveProperty("i");
|
||||
expect(placement).toHaveProperty("x");
|
||||
expect(placement).toHaveProperty("y");
|
||||
expect(placement).toHaveProperty("w");
|
||||
expect(placement).toHaveProperty("h");
|
||||
});
|
||||
|
||||
it("should call onClose when close button is clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(<WidgetPicker {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Close"));
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should place new widgets after existing layout items", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const onAdd = vi.fn();
|
||||
const existingLayout: WidgetPlacement[] = [
|
||||
{ i: "test-1", x: 0, y: 0, w: 6, h: 3 },
|
||||
{ i: "test-2", x: 0, y: 3, w: 6, h: 2 },
|
||||
];
|
||||
render(<WidgetPicker {...defaultProps} onAddWidget={onAdd} currentLayout={existingLayout} />);
|
||||
|
||||
const addButtons = screen.getAllByText("Add");
|
||||
await user.click(addButtons[0]!);
|
||||
|
||||
const placement = onAdd.mock.calls[0]![0] as WidgetPlacement;
|
||||
// Should be placed at y=5 (3+2) to avoid overlap
|
||||
expect(placement.y).toBe(5);
|
||||
});
|
||||
});
|
||||
25
apps/web/src/components/widgets/defaultLayout.ts
Normal file
25
apps/web/src/components/widgets/defaultLayout.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Default dashboard layout — used when a user has no saved layout.
|
||||
*
|
||||
* Widget ID format: "WidgetType-default" where the prefix before the
|
||||
* first "-" must match a key in WidgetRegistry.
|
||||
*
|
||||
* Grid: 12 columns, 100px row height.
|
||||
*/
|
||||
|
||||
import type { WidgetPlacement } from "@mosaic/shared";
|
||||
|
||||
export const DEFAULT_LAYOUT: WidgetPlacement[] = [
|
||||
// Row 0 — top row (3 widgets, 4 cols each)
|
||||
{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2, minW: 1, minH: 2, maxW: 4 },
|
||||
{ i: "CalendarWidget-default", x: 4, y: 0, w: 4, h: 2, minW: 2, minH: 2, maxW: 4 },
|
||||
{ i: "AgentStatusWidget-default", x: 8, y: 0, w: 4, h: 2, minW: 1, minH: 2, maxW: 3 },
|
||||
|
||||
// Row 2 — middle row
|
||||
{ i: "ActiveProjectsWidget-default", x: 0, y: 2, w: 4, h: 3, minW: 2, minH: 2, maxW: 4 },
|
||||
{ i: "TaskProgressWidget-default", x: 4, y: 2, w: 4, h: 2, minW: 1, minH: 2, maxW: 3 },
|
||||
{ i: "OrchestratorEventsWidget-default", x: 8, y: 2, w: 4, h: 2, minW: 1, minH: 2, maxW: 4 },
|
||||
|
||||
// Row 4 — bottom
|
||||
{ i: "QuickCaptureWidget-default", x: 4, y: 4, w: 4, h: 1, minW: 2, minH: 1, maxW: 4, maxH: 2 },
|
||||
];
|
||||
@@ -14,6 +14,7 @@ import type { ChatResponse } from "@/lib/api/chat";
|
||||
// Mock the API modules - use importOriginal to preserve types/enums
|
||||
vi.mock("@/lib/api/chat", () => ({
|
||||
sendChatMessage: vi.fn(),
|
||||
streamChatMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/ideas", async (importOriginal) => {
|
||||
@@ -30,6 +31,9 @@ vi.mock("@/lib/api/ideas", async (importOriginal) => {
|
||||
const mockSendChatMessage = chatApi.sendChatMessage as MockedFunction<
|
||||
typeof chatApi.sendChatMessage
|
||||
>;
|
||||
const mockStreamChatMessage = chatApi.streamChatMessage as MockedFunction<
|
||||
typeof chatApi.streamChatMessage
|
||||
>;
|
||||
const mockCreateConversation = ideasApi.createConversation as MockedFunction<
|
||||
typeof ideasApi.createConversation
|
||||
>;
|
||||
@@ -70,9 +74,62 @@ function createMockIdea(id: string, title: string, content: string): 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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: streaming fails so tests exercise the fallback path
|
||||
makeStreamFail();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -87,13 +144,19 @@ describe("useChat", () => {
|
||||
expect(result.current.messages[0]?.role).toBe("assistant");
|
||||
expect(result.current.messages[0]?.id).toBe("welcome");
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.conversationId).toBeNull();
|
||||
});
|
||||
|
||||
it("should expose abortStream function", () => {
|
||||
const { result } = renderHook(() => useChat());
|
||||
expect(typeof result.current.abortStream).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("should add user message and assistant response", async () => {
|
||||
describe("sendMessage (fallback path when streaming fails)", () => {
|
||||
it("should add user message and assistant response via fallback", async () => {
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Hello there!"));
|
||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
@@ -119,47 +182,13 @@ describe("useChat", () => {
|
||||
});
|
||||
|
||||
expect(mockSendChatMessage).not.toHaveBeenCalled();
|
||||
expect(mockStreamChatMessage).not.toHaveBeenCalled();
|
||||
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 () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockRejectedValueOnce(new Error("API Error"));
|
||||
|
||||
const onError = vi.fn();
|
||||
@@ -171,46 +200,178 @@ describe("useChat", () => {
|
||||
|
||||
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
||||
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
||||
// Should have welcome + user + error message
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
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", () => {
|
||||
it("should not lose messages on rapid sequential sends", async () => {
|
||||
// This test verifies that functional state updates prevent message loss
|
||||
// when multiple messages are sent in quick succession
|
||||
|
||||
let callCount = 0;
|
||||
mockSendChatMessage.mockImplementation(async (): Promise<ChatResponse> => {
|
||||
callCount++;
|
||||
// Small delay to simulate network
|
||||
await Promise.resolve();
|
||||
return createMockChatResponse(`Response ${String(callCount)}`);
|
||||
});
|
||||
// Use streaming success path for deterministic behavior
|
||||
makeStreamSucceed(["Response 1"]);
|
||||
|
||||
mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
// Send first message
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Message 1");
|
||||
});
|
||||
|
||||
// Verify first message cycle complete
|
||||
expect(result.current.messages).toHaveLength(3); // welcome + user1 + assistant1
|
||||
|
||||
// Send second message
|
||||
makeStreamSucceed(["Response 2"]);
|
||||
|
||||
await act(async () => {
|
||||
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
|
||||
|
||||
// Verify message order and content
|
||||
const userMessages = result.current.messages.filter((m) => m.role === "user");
|
||||
expect(userMessages).toHaveLength(2);
|
||||
expect(userMessages[0]?.content).toBe("Message 1");
|
||||
@@ -218,69 +379,56 @@ describe("useChat", () => {
|
||||
});
|
||||
|
||||
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", ""));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
// Track message count after each operation
|
||||
const messageCounts: number[] = [];
|
||||
|
||||
makeStreamSucceed(["R1"]);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Test 1");
|
||||
});
|
||||
messageCounts.push(result.current.messages.length);
|
||||
|
||||
makeStreamSucceed(["R2"]);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Test 2");
|
||||
});
|
||||
messageCounts.push(result.current.messages.length);
|
||||
|
||||
makeStreamSucceed(["R3"]);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Test 3");
|
||||
});
|
||||
messageCounts.push(result.current.messages.length);
|
||||
|
||||
// Should accumulate: 3, 5, 7 (welcome + pairs of user/assistant)
|
||||
expect(messageCounts).toEqual([3, 5, 7]);
|
||||
|
||||
// Verify final state has all messages
|
||||
expect(result.current.messages).toHaveLength(7);
|
||||
const userMessages = result.current.messages.filter((m) => m.role === "user");
|
||||
expect(userMessages).toHaveLength(3);
|
||||
});
|
||||
|
||||
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", ""));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
makeStreamSucceed(["First response"]);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Query 1");
|
||||
});
|
||||
|
||||
makeStreamSucceed(["Second response"]);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Query 2");
|
||||
});
|
||||
|
||||
makeStreamSucceed(["Third response"]);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Query 3");
|
||||
});
|
||||
|
||||
// Verify messages are in correct order
|
||||
const messages = result.current.messages;
|
||||
expect(messages[0]?.id).toBe("welcome");
|
||||
expect(messages[1]?.content).toBe("Query 1");
|
||||
@@ -337,14 +485,12 @@ describe("useChat", () => {
|
||||
await result.current.loadConversation("conv-bad");
|
||||
});
|
||||
|
||||
// Should fall back to welcome message
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]?.id).toBe("welcome");
|
||||
});
|
||||
|
||||
it("should fall back to welcome message when stored data has wrong shape", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
// Valid JSON but wrong shape (object instead of array, missing required fields)
|
||||
mockGetIdea.mockResolvedValueOnce(
|
||||
createMockIdea("conv-bad", "Wrong Shape", JSON.stringify({ not: "an array" }))
|
||||
);
|
||||
@@ -408,7 +554,6 @@ describe("useChat", () => {
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
// Should resolve without throwing - errors are handled internally
|
||||
await act(async () => {
|
||||
await expect(result.current.loadConversation("conv-err")).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -419,19 +564,17 @@ describe("useChat", () => {
|
||||
|
||||
describe("startNewConversation", () => {
|
||||
it("should reset to initial state", async () => {
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
|
||||
makeStreamSucceed(["Response"]);
|
||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
// Send a message to have some state
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Hello");
|
||||
});
|
||||
|
||||
expect(result.current.messages.length).toBeGreaterThan(1);
|
||||
|
||||
// Start new conversation
|
||||
act(() => {
|
||||
result.current.startNewConversation();
|
||||
});
|
||||
@@ -446,6 +589,7 @@ describe("useChat", () => {
|
||||
describe("clearError", () => {
|
||||
it("should clear error state", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Test error"));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
@@ -467,6 +611,7 @@ describe("useChat", () => {
|
||||
describe("error context logging", () => {
|
||||
it("should log comprehensive error context when sendMessage fails", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockRejectedValueOnce(new Error("LLM timeout"));
|
||||
|
||||
const { result } = renderHook(() => useChat({ model: "llama3.2" }));
|
||||
@@ -489,6 +634,7 @@ describe("useChat", () => {
|
||||
|
||||
it("should truncate long message previews to 50 characters", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Failed"));
|
||||
|
||||
const longMessage = "A".repeat(100);
|
||||
@@ -509,9 +655,10 @@ describe("useChat", () => {
|
||||
|
||||
it("should include message count in error context", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
// First successful message
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK"));
|
||||
// First successful message via streaming
|
||||
makeStreamSucceed(["OK"]);
|
||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
@@ -520,14 +667,14 @@ describe("useChat", () => {
|
||||
await result.current.sendMessage("First");
|
||||
});
|
||||
|
||||
// Second message fails
|
||||
// Second message: streaming fails, fallback fails
|
||||
makeStreamFail();
|
||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Fail"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Second");
|
||||
});
|
||||
|
||||
// messageCount should reflect messages including the new user message
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to send chat message",
|
||||
expect.objectContaining({
|
||||
@@ -540,6 +687,7 @@ describe("useChat", () => {
|
||||
describe("LLM vs persistence error separation", () => {
|
||||
it("should show LLM error and add error message to chat when API fails", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available"));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
@@ -549,13 +697,29 @@ describe("useChat", () => {
|
||||
});
|
||||
|
||||
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[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);
|
||||
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!"));
|
||||
mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost"));
|
||||
|
||||
@@ -565,16 +729,14 @@ describe("useChat", () => {
|
||||
await result.current.sendMessage("Hello");
|
||||
});
|
||||
|
||||
// Assistant message should still be visible
|
||||
expect(result.current.messages).toHaveLength(3); // welcome + user + assistant
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
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");
|
||||
});
|
||||
|
||||
it("should log with PERSISTENCE_ERROR type when save fails", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
|
||||
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 ctx: unknown = call[1];
|
||||
return (
|
||||
@@ -606,8 +767,9 @@ describe("useChat", () => {
|
||||
|
||||
it("should use different user-facing messages for LLM vs save errors", async () => {
|
||||
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"));
|
||||
const { result: result1 } = renderHook(() => useChat());
|
||||
|
||||
@@ -617,8 +779,8 @@ describe("useChat", () => {
|
||||
|
||||
const llmError = result1.current.error;
|
||||
|
||||
// Test save error message
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK"));
|
||||
// Save error path (streaming succeeds, save fails)
|
||||
makeStreamSucceed(["OK"]);
|
||||
mockCreateConversation.mockRejectedValueOnce(new Error("DB down"));
|
||||
const { result: result2 } = renderHook(() => useChat());
|
||||
|
||||
@@ -628,7 +790,6 @@ describe("useChat", () => {
|
||||
|
||||
const saveError = result2.current.error;
|
||||
|
||||
// They should be different
|
||||
expect(llmError).toBe("Unable to send message. Please try again.");
|
||||
expect(saveError).toContain("Message sent but failed to save");
|
||||
expect(llmError).not.toEqual(saveError);
|
||||
@@ -636,6 +797,7 @@ describe("useChat", () => {
|
||||
|
||||
it("should handle non-Error throws from LLM API", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockRejectedValueOnce("string error");
|
||||
|
||||
const onError = vi.fn();
|
||||
@@ -652,7 +814,8 @@ describe("useChat", () => {
|
||||
|
||||
it("should handle non-Error throws from persistence layer", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK"));
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
makeStreamSucceed(["OK"]);
|
||||
mockCreateConversation.mockRejectedValueOnce("DB string error");
|
||||
|
||||
const onError = vi.fn();
|
||||
@@ -662,7 +825,6 @@ describe("useChat", () => {
|
||||
await result.current.sendMessage("Hello");
|
||||
});
|
||||
|
||||
// Assistant message should still be visible
|
||||
expect(result.current.messages[2]?.content).toBe("OK");
|
||||
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
||||
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
||||
@@ -670,8 +832,9 @@ describe("useChat", () => {
|
||||
|
||||
it("should handle updateConversation failure for existing conversations", async () => {
|
||||
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"));
|
||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
@@ -683,7 +846,8 @@ describe("useChat", () => {
|
||||
|
||||
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"));
|
||||
mockUpdateConversation.mockRejectedValueOnce(new Error("Connection reset"));
|
||||
|
||||
@@ -691,8 +855,10 @@ describe("useChat", () => {
|
||||
await result.current.sendMessage("Second");
|
||||
});
|
||||
|
||||
// Assistant message should still be visible
|
||||
expect(result.current.messages[4]?.content).toBe("Second response");
|
||||
const assistantMessages = result.current.messages.filter(
|
||||
(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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
*/
|
||||
|
||||
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 { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
|
||||
|
||||
@@ -33,10 +37,12 @@ export interface UseChatOptions {
|
||||
export interface UseChatReturn {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
isStreaming: boolean;
|
||||
error: string | null;
|
||||
conversationId: string | null;
|
||||
conversationTitle: string | null;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
abortStream: () => void;
|
||||
loadConversation: (ideaId: string) => Promise<void>;
|
||||
startNewConversation: (projectId?: string | null) => void;
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||
@@ -66,6 +72,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([WELCOME_MESSAGE]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conversationId, setConversationId] = 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);
|
||||
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
|
||||
*/
|
||||
@@ -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(
|
||||
async (msgs: Message[], title: string): Promise<string> => {
|
||||
const content = serializeMessages(msgs);
|
||||
const currentConvId = conversationIdRef.current;
|
||||
|
||||
if (conversationId) {
|
||||
// Update existing conversation
|
||||
await updateConversation(conversationId, content, title);
|
||||
return conversationId;
|
||||
if (currentConvId) {
|
||||
await updateConversation(currentConvId, content, title);
|
||||
return currentConvId;
|
||||
} else {
|
||||
// Create new conversation
|
||||
const idea = await createConversation(title, content, projectIdRef.current ?? undefined);
|
||||
setConversationId(idea.id);
|
||||
setConversationTitle(title);
|
||||
conversationIdRef.current = idea.id;
|
||||
conversationTitleRef.current = title;
|
||||
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(
|
||||
async (content: string): Promise<void> => {
|
||||
if (!content.trim() || isLoading) {
|
||||
if (!content.trim() || isLoading || isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
id: `user-${Date.now().toString()}`,
|
||||
id: `user-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Add user message immediately using functional update
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev, userMessage];
|
||||
messagesRef.current = updated;
|
||||
@@ -165,95 +195,186 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
setIsLoading(true);
|
||||
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 {
|
||||
// Prepare API request - use ref to get current messages (prevents stale closure)
|
||||
const currentMessages = messagesRef.current;
|
||||
const apiMessages = convertToApiMessages(currentMessages);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let hasReceivedData = false;
|
||||
|
||||
const request = {
|
||||
model,
|
||||
messages: apiMessages,
|
||||
...(temperature !== undefined && { temperature }),
|
||||
...(maxTokens !== undefined && { maxTokens }),
|
||||
...(systemPrompt !== undefined && { systemPrompt }),
|
||||
};
|
||||
streamChatMessage(
|
||||
request,
|
||||
(chunk: string) => {
|
||||
if (!hasReceivedData) {
|
||||
hasReceivedData = true;
|
||||
setIsLoading(false);
|
||||
setIsStreaming(true);
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev, { ...placeholderMessage }];
|
||||
messagesRef.current = updated;
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
// Call LLM API
|
||||
const response = await sendChatMessage(request);
|
||||
|
||||
// Create assistant message
|
||||
const assistantMessage: Message = {
|
||||
id: `assistant-${Date.now().toString()}`,
|
||||
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),
|
||||
};
|
||||
|
||||
// Add assistant message using functional update
|
||||
let finalMessages: Message[] = [];
|
||||
setMessages((prev) => {
|
||||
finalMessages = [...prev, assistantMessage];
|
||||
messagesRef.current = finalMessages;
|
||||
return finalMessages;
|
||||
setMessages((prev) => {
|
||||
const updated = prev.map((msg) =>
|
||||
msg.id === assistantMessageId ? { ...msg, content: msg.content + chunk } : msg
|
||||
);
|
||||
messagesRef.current = updated;
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
() => {
|
||||
streamingSucceeded = true;
|
||||
setIsStreaming(false);
|
||||
abortControllerRef.current = null;
|
||||
resolve();
|
||||
},
|
||||
(err: Error) => {
|
||||
reject(err);
|
||||
},
|
||||
controller.signal
|
||||
);
|
||||
});
|
||||
} 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
|
||||
const isFirstMessage =
|
||||
!conversationId && finalMessages.filter((m) => m.role === "user").length === 1;
|
||||
const title = isFirstMessage
|
||||
? generateTitle(content)
|
||||
: (conversationTitle ?? "Chat Conversation");
|
||||
|
||||
// Save conversation (separate error handling from LLM errors)
|
||||
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,
|
||||
detail: saveErrorMsg,
|
||||
// Remove placeholder if no content was received
|
||||
setMessages((prev) => {
|
||||
const assistantMsg = prev.find((m) => m.id === assistantMessageId);
|
||||
if (assistantMsg?.content === "") {
|
||||
const updated = prev.filter((m) => m.id !== assistantMessageId);
|
||||
messagesRef.current = updated;
|
||||
return updated;
|
||||
}
|
||||
messagesRef.current = prev;
|
||||
return prev;
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to send message";
|
||||
setError("Unable to send message. Please try again.");
|
||||
onError?.(err instanceof Error ? err : new Error(errorMsg));
|
||||
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(),
|
||||
|
||||
// Streaming failed — fall back to non-streaming
|
||||
console.warn("Streaming failed, falling back to non-streaming", {
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
});
|
||||
|
||||
// Add error message to chat
|
||||
const errorMessage: Message = {
|
||||
id: `error-${String(Date.now())}`,
|
||||
role: "assistant",
|
||||
content: "Something went wrong. Please try again.",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setMessages((prev) => {
|
||||
const withoutPlaceholder = prev.filter((m) => m.id !== assistantMessageId);
|
||||
messagesRef.current = withoutPlaceholder;
|
||||
return withoutPlaceholder;
|
||||
});
|
||||
setIsStreaming(false);
|
||||
|
||||
try {
|
||||
const response = await sendChatMessage(request);
|
||||
|
||||
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,
|
||||
conversationId,
|
||||
conversationTitle,
|
||||
isStreaming,
|
||||
model,
|
||||
temperature,
|
||||
maxTokens,
|
||||
@@ -280,6 +401,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
setMessages(msgs);
|
||||
setConversationId(idea.id);
|
||||
setConversationTitle(idea.title ?? null);
|
||||
conversationIdRef.current = idea.id;
|
||||
conversationTitleRef.current = idea.title ?? null;
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to load conversation";
|
||||
setError("Unable to load conversation. Please try again.");
|
||||
@@ -305,6 +428,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
setConversationId(null);
|
||||
setConversationTitle(null);
|
||||
setError(null);
|
||||
conversationIdRef.current = null;
|
||||
conversationTitleRef.current = null;
|
||||
projectIdRef.current = newProjectId ?? null;
|
||||
}, []);
|
||||
|
||||
@@ -318,10 +443,12 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
isStreaming,
|
||||
error,
|
||||
conversationId,
|
||||
conversationTitle,
|
||||
sendMessage,
|
||||
abortStream,
|
||||
loadConversation,
|
||||
startNewConversation,
|
||||
setMessages,
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* 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 {
|
||||
role: "system" | "user" | "assistant";
|
||||
@@ -31,6 +32,19 @@ export interface ChatResponse {
|
||||
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
|
||||
*/
|
||||
@@ -39,19 +53,122 @@ export async function sendChatMessage(request: ChatRequest): Promise<ChatRespons
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a chat message from the LLM (not implemented yet)
|
||||
* TODO: Implement streaming support
|
||||
* Get or refresh the CSRF token for streaming requests.
|
||||
*/
|
||||
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(
|
||||
request: ChatRequest,
|
||||
onChunk: (chunk: string) => void,
|
||||
onComplete: () => void,
|
||||
onError: (error: Error) => void
|
||||
onError: (error: Error) => void,
|
||||
signal?: AbortSignal
|
||||
): void {
|
||||
// Streaming implementation would go here
|
||||
void request;
|
||||
void onChunk;
|
||||
void onComplete;
|
||||
void onError;
|
||||
throw new Error("Streaming not implemented yet");
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
const csrfToken = await ensureCsrfTokenForStream();
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/llm/chat`, {
|
||||
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)));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -7,84 +7,51 @@ import type { Event } from "@mosaic/shared";
|
||||
import { apiGet, type ApiResponse } from "./client";
|
||||
|
||||
export interface EventFilters {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
workspaceId?: string;
|
||||
/** Filter events starting from this date (inclusive) */
|
||||
startFrom?: Date;
|
||||
/** Filter events starting up to this date (inclusive) */
|
||||
startTo?: Date;
|
||||
/** Filter by project ID */
|
||||
projectId?: string;
|
||||
/** Filter by all-day events */
|
||||
allDay?: boolean;
|
||||
/** Page number (1-based) */
|
||||
page?: number;
|
||||
/** Items per page (max 100) */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events with optional filters
|
||||
*
|
||||
* @param workspaceId - Workspace ID sent via X-Workspace-Id header
|
||||
* @param filters - Optional query parameter filters
|
||||
*/
|
||||
export async function fetchEvents(filters?: EventFilters): Promise<Event[]> {
|
||||
export async function fetchEvents(workspaceId?: string, filters?: EventFilters): Promise<Event[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters?.startDate) {
|
||||
params.append("startDate", filters.startDate.toISOString());
|
||||
if (filters?.startFrom) {
|
||||
params.append("startFrom", filters.startFrom.toISOString());
|
||||
}
|
||||
if (filters?.endDate) {
|
||||
params.append("endDate", filters.endDate.toISOString());
|
||||
if (filters?.startTo) {
|
||||
params.append("startTo", filters.startTo.toISOString());
|
||||
}
|
||||
if (filters?.workspaceId) {
|
||||
params.append("workspaceId", filters.workspaceId);
|
||||
if (filters?.projectId) {
|
||||
params.append("projectId", filters.projectId);
|
||||
}
|
||||
if (filters?.allDay !== undefined) {
|
||||
params.append("allDay", String(filters.allDay));
|
||||
}
|
||||
if (filters?.page !== undefined) {
|
||||
params.append("page", String(filters.page));
|
||||
}
|
||||
if (filters?.limit !== undefined) {
|
||||
params.append("limit", String(filters.limit));
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/api/events?${queryString}` : "/api/events";
|
||||
|
||||
const response = await apiGet<ApiResponse<Event[]>>(endpoint);
|
||||
const response = await apiGet<ApiResponse<Event[]>>(endpoint, workspaceId);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock events for development (until backend endpoints are ready)
|
||||
*/
|
||||
export const mockEvents: Event[] = [
|
||||
{
|
||||
id: "event-1",
|
||||
title: "Team standup",
|
||||
description: "Daily sync meeting",
|
||||
startTime: new Date("2026-01-29T10:00:00"),
|
||||
endTime: new Date("2026-01-29T10:30:00"),
|
||||
allDay: false,
|
||||
location: "Zoom",
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "event-2",
|
||||
title: "Project review",
|
||||
description: "Quarterly project review session",
|
||||
startTime: new Date("2026-01-30T14:00:00"),
|
||||
endTime: new Date("2026-01-30T15:30:00"),
|
||||
allDay: false,
|
||||
location: "Conference Room A",
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "event-3",
|
||||
title: "Focus time",
|
||||
description: "Dedicated time for deep work",
|
||||
startTime: new Date("2026-01-31T09:00:00"),
|
||||
endTime: new Date("2026-01-31T12:00:00"),
|
||||
allDay: false,
|
||||
location: null,
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from "./teams";
|
||||
export * from "./personalities";
|
||||
export * from "./telemetry";
|
||||
export * from "./dashboard";
|
||||
export * from "./projects";
|
||||
|
||||
@@ -8,8 +8,9 @@ import type {
|
||||
KnowledgeTag,
|
||||
KnowledgeEntryVersionWithAuthor,
|
||||
PaginatedResponse,
|
||||
EntryStatus,
|
||||
Visibility,
|
||||
} from "@mosaic/shared";
|
||||
import { EntryStatus, Visibility } from "@mosaic/shared";
|
||||
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
|
||||
|
||||
export interface EntryFilters {
|
||||
@@ -370,241 +371,3 @@ export async function fetchKnowledgeGraph(filters?: {
|
||||
const endpoint = queryString ? `/api/knowledge/graph?${queryString}` : "/api/knowledge/graph";
|
||||
return apiGet(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock entries for development (until backend endpoints are ready)
|
||||
*/
|
||||
export const mockEntries: KnowledgeEntryWithTags[] = [
|
||||
{
|
||||
id: "entry-1",
|
||||
workspaceId: "workspace-1",
|
||||
slug: "getting-started",
|
||||
title: "Getting Started with Mosaic Stack",
|
||||
content: "# Getting Started\n\nWelcome to Mosaic Stack...",
|
||||
contentHtml: "<h1>Getting Started</h1><p>Welcome to Mosaic Stack...</p>",
|
||||
summary: "A comprehensive guide to getting started with the Mosaic Stack platform.",
|
||||
status: EntryStatus.PUBLISHED,
|
||||
visibility: Visibility.PUBLIC,
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
createdAt: new Date("2026-01-20"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
tags: [
|
||||
{
|
||||
id: "tag-1",
|
||||
workspaceId: "workspace-1",
|
||||
name: "Tutorial",
|
||||
slug: "tutorial",
|
||||
color: "#3B82F6",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "tag-2",
|
||||
workspaceId: "workspace-1",
|
||||
name: "Onboarding",
|
||||
slug: "onboarding",
|
||||
color: "#10B981",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "entry-2",
|
||||
workspaceId: "workspace-1",
|
||||
slug: "architecture-overview",
|
||||
title: "Architecture Overview",
|
||||
content: "# Architecture\n\nThe Mosaic Stack architecture...",
|
||||
contentHtml: "<h1>Architecture</h1><p>The Mosaic Stack architecture...</p>",
|
||||
summary: "Overview of the system architecture and design patterns used in Mosaic Stack.",
|
||||
status: EntryStatus.PUBLISHED,
|
||||
visibility: Visibility.WORKSPACE,
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
createdAt: new Date("2026-01-15"),
|
||||
updatedAt: new Date("2026-01-27"),
|
||||
tags: [
|
||||
{
|
||||
id: "tag-3",
|
||||
workspaceId: "workspace-1",
|
||||
name: "Architecture",
|
||||
slug: "architecture",
|
||||
color: "#8B5CF6",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "tag-4",
|
||||
workspaceId: "workspace-1",
|
||||
name: "Technical",
|
||||
slug: "technical",
|
||||
color: "#F59E0B",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "entry-3",
|
||||
workspaceId: "workspace-1",
|
||||
slug: "api-documentation-draft",
|
||||
title: "API Documentation (Draft)",
|
||||
content: "# API Docs\n\nWork in progress...",
|
||||
contentHtml: "<h1>API Docs</h1><p>Work in progress...</p>",
|
||||
summary: "Comprehensive API documentation for developers.",
|
||||
status: EntryStatus.DRAFT,
|
||||
visibility: Visibility.PRIVATE,
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
createdAt: new Date("2026-01-29"),
|
||||
updatedAt: new Date("2026-01-29"),
|
||||
tags: [
|
||||
{
|
||||
id: "tag-4",
|
||||
workspaceId: "workspace-1",
|
||||
name: "Technical",
|
||||
slug: "technical",
|
||||
color: "#F59E0B",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "tag-5",
|
||||
workspaceId: "workspace-1",
|
||||
name: "API",
|
||||
slug: "api",
|
||||
color: "#EF4444",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "entry-4",
|
||||
workspaceId: "workspace-1",
|
||||
slug: "deployment-guide",
|
||||
title: "Deployment Guide",
|
||||
content: "# Deployment\n\nHow to deploy Mosaic Stack...",
|
||||
contentHtml: "<h1>Deployment</h1><p>How to deploy Mosaic Stack...</p>",
|
||||
summary: "Step-by-step guide for deploying Mosaic Stack to production.",
|
||||
status: EntryStatus.PUBLISHED,
|
||||
visibility: Visibility.WORKSPACE,
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
createdAt: new Date("2026-01-18"),
|
||||
updatedAt: new Date("2026-01-25"),
|
||||
tags: [
|
||||
{
|
||||
id: "tag-6",
|
||||
workspaceId: "workspace-1",
|
||||
name: "DevOps",
|
||||
slug: "devops",
|
||||
color: "#14B8A6",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "tag-1",
|
||||
workspaceId: "workspace-1",
|
||||
name: "Tutorial",
|
||||
slug: "tutorial",
|
||||
color: "#3B82F6",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "entry-5",
|
||||
workspaceId: "workspace-1",
|
||||
slug: "old-meeting-notes",
|
||||
title: "Q4 2025 Meeting Notes",
|
||||
content: "# Meeting Notes\n\nOld archived notes...",
|
||||
contentHtml: "<h1>Meeting Notes</h1><p>Old archived notes...</p>",
|
||||
summary: "Meeting notes from Q4 2025 - archived for reference.",
|
||||
status: EntryStatus.ARCHIVED,
|
||||
visibility: Visibility.PRIVATE,
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
createdAt: new Date("2025-12-15"),
|
||||
updatedAt: new Date("2026-01-05"),
|
||||
tags: [
|
||||
{
|
||||
id: "tag-7",
|
||||
workspaceId: "workspace-1",
|
||||
name: "Meetings",
|
||||
slug: "meetings",
|
||||
color: "#6B7280",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockTags: KnowledgeTag[] = [
|
||||
{
|
||||
id: "tag-1",
|
||||
workspaceId: "workspace-1",
|
||||
name: "Tutorial",
|
||||
slug: "tutorial",
|
||||
color: "#3B82F6",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "tag-2",
|
||||
workspaceId: "workspace-1",
|
||||
name: "Onboarding",
|
||||
slug: "onboarding",
|
||||
color: "#10B981",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "tag-3",
|
||||
workspaceId: "workspace-1",
|
||||
name: "Architecture",
|
||||
slug: "architecture",
|
||||
color: "#8B5CF6",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "tag-4",
|
||||
workspaceId: "workspace-1",
|
||||
name: "Technical",
|
||||
slug: "technical",
|
||||
color: "#F59E0B",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "tag-5",
|
||||
workspaceId: "workspace-1",
|
||||
name: "API",
|
||||
slug: "api",
|
||||
color: "#EF4444",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "tag-6",
|
||||
workspaceId: "workspace-1",
|
||||
name: "DevOps",
|
||||
slug: "devops",
|
||||
color: "#14B8A6",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "tag-7",
|
||||
workspaceId: "workspace-1",
|
||||
name: "Meetings",
|
||||
slug: "meetings",
|
||||
color: "#6B7280",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
54
apps/web/src/lib/api/layouts.ts
Normal file
54
apps/web/src/lib/api/layouts.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Layout API client — CRUD for user dashboard layouts
|
||||
*/
|
||||
|
||||
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
|
||||
import { apiGet, apiPost, apiPatch } from "./client";
|
||||
|
||||
export interface CreateLayoutPayload {
|
||||
name: string;
|
||||
isDefault?: boolean;
|
||||
layout: WidgetPlacement[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateLayoutPayload {
|
||||
name?: string;
|
||||
isDefault?: boolean;
|
||||
layout?: WidgetPlacement[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the user's default layout for the active workspace.
|
||||
* Returns null if no layout exists (404).
|
||||
*/
|
||||
export async function fetchDefaultLayout(workspaceId: string): Promise<UserLayout | null> {
|
||||
try {
|
||||
return await apiGet<UserLayout>("/api/layouts/default", workspaceId);
|
||||
} catch {
|
||||
// 404 = no layout yet — not an error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new layout.
|
||||
*/
|
||||
export async function createLayout(
|
||||
workspaceId: string,
|
||||
payload: CreateLayoutPayload
|
||||
): Promise<UserLayout> {
|
||||
return apiPost<UserLayout>("/api/layouts", payload, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing layout (partial patch).
|
||||
*/
|
||||
export async function updateLayout(
|
||||
workspaceId: string,
|
||||
layoutId: string,
|
||||
payload: UpdateLayoutPayload
|
||||
): Promise<UserLayout> {
|
||||
return apiPatch<UserLayout>(`/api/layouts/${layoutId}`, payload, workspaceId);
|
||||
}
|
||||
104
apps/web/src/lib/api/projects.ts
Normal file
104
apps/web/src/lib/api/projects.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Projects API Client
|
||||
* Handles project-related API requests
|
||||
*/
|
||||
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "./client";
|
||||
|
||||
/**
|
||||
* Project status enum (matches backend ProjectStatus)
|
||||
*/
|
||||
export enum ProjectStatus {
|
||||
PLANNING = "PLANNING",
|
||||
ACTIVE = "ACTIVE",
|
||||
PAUSED = "PAUSED",
|
||||
COMPLETED = "COMPLETED",
|
||||
ARCHIVED = "ARCHIVED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Project response interface (matches Prisma Project model)
|
||||
*/
|
||||
export interface Project {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: ProjectStatus;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
creatorId: string;
|
||||
domainId: string | null;
|
||||
color: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for creating a new project
|
||||
*/
|
||||
export interface CreateProjectDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
status?: ProjectStatus;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
color?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating an existing project
|
||||
*/
|
||||
export interface UpdateProjectDto {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
status?: ProjectStatus;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
color?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all projects for a workspace
|
||||
*/
|
||||
export async function fetchProjects(workspaceId?: string): Promise<Project[]> {
|
||||
return apiGet<Project[]>("/api/projects", workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single project by ID
|
||||
*/
|
||||
export async function fetchProject(id: string, workspaceId?: string): Promise<Project> {
|
||||
return apiGet<Project>(`/api/projects/${id}`, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project
|
||||
*/
|
||||
export async function createProject(
|
||||
data: CreateProjectDto,
|
||||
workspaceId?: string
|
||||
): Promise<Project> {
|
||||
return apiPost<Project>("/api/projects", data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing project
|
||||
*/
|
||||
export async function updateProject(
|
||||
id: string,
|
||||
data: UpdateProjectDto,
|
||||
workspaceId?: string
|
||||
): Promise<Project> {
|
||||
return apiPatch<Project>(`/api/projects/${id}`, data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project
|
||||
*/
|
||||
export async function deleteProject(id: string, workspaceId?: string): Promise<void> {
|
||||
await apiDelete<Record<string, never>>(`/api/projects/${id}`, workspaceId);
|
||||
}
|
||||
163
apps/web/src/lib/api/runner-jobs.ts
Normal file
163
apps/web/src/lib/api/runner-jobs.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Runner Jobs API Client
|
||||
* Handles runner-job-related API requests
|
||||
*/
|
||||
|
||||
import { apiGet, type ApiResponse } from "./client";
|
||||
|
||||
/**
|
||||
* Runner job status enum (matches backend RunnerJobStatus)
|
||||
*/
|
||||
export enum RunnerJobStatus {
|
||||
PENDING = "PENDING",
|
||||
QUEUED = "QUEUED",
|
||||
RUNNING = "RUNNING",
|
||||
COMPLETED = "COMPLETED",
|
||||
FAILED = "FAILED",
|
||||
CANCELLED = "CANCELLED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Runner job response interface (matches Prisma RunnerJob model)
|
||||
*/
|
||||
export interface RunnerJob {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
agentTaskId: string | null;
|
||||
type: string;
|
||||
status: RunnerJobStatus;
|
||||
priority: number;
|
||||
progressPercent: number;
|
||||
version: number;
|
||||
result: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters for querying runner jobs
|
||||
*/
|
||||
export interface RunnerJobFilters {
|
||||
workspaceId?: string;
|
||||
status?: RunnerJobStatus | RunnerJobStatus[];
|
||||
type?: string;
|
||||
agentTaskId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated runner jobs response
|
||||
*/
|
||||
export interface PaginatedRunnerJobs {
|
||||
data: RunnerJob[];
|
||||
meta?: {
|
||||
total?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch runner jobs with optional filters
|
||||
*/
|
||||
export async function fetchRunnerJobs(filters?: RunnerJobFilters): Promise<RunnerJob[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters?.status) {
|
||||
const statuses = Array.isArray(filters.status) ? filters.status : [filters.status];
|
||||
for (const s of statuses) {
|
||||
params.append("status", s);
|
||||
}
|
||||
}
|
||||
if (filters?.type) {
|
||||
params.append("type", filters.type);
|
||||
}
|
||||
if (filters?.agentTaskId) {
|
||||
params.append("agentTaskId", filters.agentTaskId);
|
||||
}
|
||||
if (filters?.page !== undefined) {
|
||||
params.append("page", String(filters.page));
|
||||
}
|
||||
if (filters?.limit !== undefined) {
|
||||
params.append("limit", String(filters.limit));
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/api/runner-jobs?${queryString}` : "/api/runner-jobs";
|
||||
|
||||
const response = await apiGet<ApiResponse<RunnerJob[]>>(endpoint, filters?.workspaceId);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single runner job by ID
|
||||
*/
|
||||
export async function fetchRunnerJob(id: string, workspaceId?: string): Promise<RunnerJob> {
|
||||
return apiGet<RunnerJob>(`/api/runner-jobs/${id}`, workspaceId);
|
||||
}
|
||||
|
||||
// ─── Job Steps ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Job step phase enum (matches backend JobStepPhase)
|
||||
*/
|
||||
export enum JobStepPhase {
|
||||
SETUP = "SETUP",
|
||||
EXECUTION = "EXECUTION",
|
||||
VALIDATION = "VALIDATION",
|
||||
CLEANUP = "CLEANUP",
|
||||
}
|
||||
|
||||
/**
|
||||
* Job step type enum (matches backend JobStepType)
|
||||
*/
|
||||
export enum JobStepType {
|
||||
COMMAND = "COMMAND",
|
||||
AI_ACTION = "AI_ACTION",
|
||||
GATE = "GATE",
|
||||
ARTIFACT = "ARTIFACT",
|
||||
}
|
||||
|
||||
/**
|
||||
* Job step status enum (matches backend JobStepStatus)
|
||||
*/
|
||||
export enum JobStepStatus {
|
||||
PENDING = "PENDING",
|
||||
RUNNING = "RUNNING",
|
||||
COMPLETED = "COMPLETED",
|
||||
FAILED = "FAILED",
|
||||
SKIPPED = "SKIPPED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Job step response interface (matches Prisma JobStep model)
|
||||
*/
|
||||
export interface JobStep {
|
||||
id: string;
|
||||
jobId: string;
|
||||
ordinal: number;
|
||||
phase: JobStepPhase;
|
||||
name: string;
|
||||
type: JobStepType;
|
||||
status: JobStepStatus;
|
||||
output: string | null;
|
||||
tokensInput: number | null;
|
||||
tokensOutput: number | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
durationMs: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch job steps for a specific runner job
|
||||
*/
|
||||
export async function fetchJobSteps(jobId: string, workspaceId?: string): Promise<JobStep[]> {
|
||||
const response = await apiGet<ApiResponse<JobStep[]>>(
|
||||
`/api/runner-jobs/${jobId}/steps`,
|
||||
workspaceId
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
import { apiGet, type ApiResponse } from "./client";
|
||||
import type { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
import { apiGet, apiPatch, type ApiResponse } from "./client";
|
||||
|
||||
export interface TaskFilters {
|
||||
status?: TaskStatus;
|
||||
@@ -36,79 +36,13 @@ export async function fetchTasks(filters?: TaskFilters): Promise<Task[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock tasks for development (until backend endpoints are ready)
|
||||
* Update a task by ID
|
||||
*/
|
||||
export const mockTasks: Task[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Review pull request",
|
||||
description: "Review and provide feedback on frontend PR",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.HIGH,
|
||||
dueDate: new Date("2026-01-29"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
title: "Update documentation",
|
||||
description: "Add setup instructions for new developers",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
dueDate: new Date("2026-01-30"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "task-3",
|
||||
title: "Plan Q1 roadmap",
|
||||
description: "Define priorities for Q1 2026",
|
||||
status: TaskStatus.NOT_STARTED,
|
||||
priority: TaskPriority.HIGH,
|
||||
dueDate: new Date("2026-02-03"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 2,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "task-4",
|
||||
title: "Research new libraries",
|
||||
description: "Evaluate options for state management",
|
||||
status: TaskStatus.PAUSED,
|
||||
priority: TaskPriority.LOW,
|
||||
dueDate: new Date("2026-02-10"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 3,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
];
|
||||
export async function updateTask(
|
||||
id: string,
|
||||
data: Partial<Task>,
|
||||
workspaceId?: string
|
||||
): Promise<Task> {
|
||||
const res = await apiPatch<ApiResponse<Task>>(`/api/tasks/${id}`, data, workspaceId);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { render, screen, act } from "@testing-library/react";
|
||||
import { ThemeProvider, useTheme } from "./ThemeProvider";
|
||||
|
||||
function ThemeConsumer(): React.JSX.Element {
|
||||
const { theme, resolvedTheme, setTheme, toggleTheme } = useTheme();
|
||||
const { theme, themeId, themeDefinition, resolvedTheme, setTheme, toggleTheme } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="themeId">{themeId}</span>
|
||||
<span data-testid="themeName">{themeDefinition.name}</span>
|
||||
<span data-testid="resolved">{resolvedTheme}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -22,6 +24,27 @@ function ThemeConsumer(): React.JSX.Element {
|
||||
>
|
||||
Set Dark
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTheme("nord");
|
||||
}}
|
||||
>
|
||||
Set Nord
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTheme("dracula");
|
||||
}}
|
||||
>
|
||||
Set Dracula
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTheme("system");
|
||||
}}
|
||||
>
|
||||
Set System
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleTheme();
|
||||
@@ -38,7 +61,9 @@ describe("ThemeProvider", (): void => {
|
||||
|
||||
beforeEach((): void => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("light", "dark");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
// Clear any inline style properties set by theme application
|
||||
document.documentElement.removeAttribute("style");
|
||||
|
||||
mockMatchMedia = vi.fn().mockReturnValue({
|
||||
matches: false,
|
||||
@@ -65,6 +90,7 @@ describe("ThemeProvider", (): void => {
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("theme")).toHaveTextContent("light");
|
||||
expect(screen.getByTestId("themeId")).toHaveTextContent("light");
|
||||
});
|
||||
|
||||
it("should NOT read from old 'jarvis-theme' storage key", (): void => {
|
||||
@@ -76,7 +102,6 @@ describe("ThemeProvider", (): void => {
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Should default to system, not read from jarvis-theme
|
||||
expect(screen.getByTestId("theme")).toHaveTextContent("system");
|
||||
});
|
||||
|
||||
@@ -106,7 +131,6 @@ describe("ThemeProvider", (): void => {
|
||||
});
|
||||
|
||||
it("should throw when useTheme is used outside provider", (): void => {
|
||||
// Suppress console.error for expected error
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||
// Intentionally empty
|
||||
});
|
||||
@@ -117,4 +141,201 @@ describe("ThemeProvider", (): void => {
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should resolve 'system' to dark when OS prefers dark", (): void => {
|
||||
mockMatchMedia.mockReturnValue({
|
||||
matches: true,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("themeId")).toHaveTextContent("dark");
|
||||
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
|
||||
});
|
||||
|
||||
it("should resolve 'system' to light when OS prefers light", (): void => {
|
||||
mockMatchMedia.mockReturnValue({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("themeId")).toHaveTextContent("light");
|
||||
expect(screen.getByTestId("resolved")).toHaveTextContent("light");
|
||||
});
|
||||
|
||||
it("should support Nord theme", (): void => {
|
||||
localStorage.setItem("mosaic-theme", "nord");
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("theme")).toHaveTextContent("nord");
|
||||
expect(screen.getByTestId("themeId")).toHaveTextContent("nord");
|
||||
expect(screen.getByTestId("themeName")).toHaveTextContent("Nord");
|
||||
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
|
||||
});
|
||||
|
||||
it("should support Dracula theme", (): void => {
|
||||
localStorage.setItem("mosaic-theme", "dracula");
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("themeId")).toHaveTextContent("dracula");
|
||||
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
|
||||
});
|
||||
|
||||
it("should support Solarized Dark theme", (): void => {
|
||||
localStorage.setItem("mosaic-theme", "solarized-dark");
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("themeId")).toHaveTextContent("solarized-dark");
|
||||
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
|
||||
});
|
||||
|
||||
it("should fall back to system for unknown theme IDs", (): void => {
|
||||
localStorage.setItem("mosaic-theme", "nonexistent-theme");
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Falls back to "system" because "nonexistent-theme" is not a valid theme ID
|
||||
expect(screen.getByTestId("theme")).toHaveTextContent("system");
|
||||
});
|
||||
|
||||
it("should switch between themes via setTheme", (): void => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Set Nord").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("themeId")).toHaveTextContent("nord");
|
||||
expect(screen.getByTestId("themeName")).toHaveTextContent("Nord");
|
||||
expect(localStorage.getItem("mosaic-theme")).toBe("nord");
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Set Dracula").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("themeId")).toHaveTextContent("dracula");
|
||||
expect(localStorage.getItem("mosaic-theme")).toBe("dracula");
|
||||
});
|
||||
|
||||
it("should toggle between dark and light", (): void => {
|
||||
localStorage.setItem("mosaic-theme", "dark");
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Toggle").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("themeId")).toHaveTextContent("light");
|
||||
expect(screen.getByTestId("resolved")).toHaveTextContent("light");
|
||||
});
|
||||
|
||||
it("should toggle from a dark theme (nord) to light", (): void => {
|
||||
localStorage.setItem("mosaic-theme", "nord");
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Toggle").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("themeId")).toHaveTextContent("light");
|
||||
expect(screen.getByTestId("resolved")).toHaveTextContent("light");
|
||||
});
|
||||
|
||||
it("should apply CSS variables on theme change", (): void => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Set Nord").click();
|
||||
});
|
||||
|
||||
// Nord's bg-900 is #2e3440
|
||||
const bgValue = document.documentElement.style.getPropertyValue("--ms-bg-900");
|
||||
expect(bgValue).toBe("#2e3440");
|
||||
});
|
||||
|
||||
it("should set data-theme attribute based on isDark", (): void => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Set Nord").click();
|
||||
});
|
||||
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Set Light").click();
|
||||
});
|
||||
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
});
|
||||
|
||||
it("should expose themeDefinition with full theme data", (): void => {
|
||||
localStorage.setItem("mosaic-theme", "dark");
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("themeName")).toHaveTextContent("Dark");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
import {
|
||||
type ThemeDefinition,
|
||||
darkTheme,
|
||||
getThemeOrDefault,
|
||||
isValidThemeId,
|
||||
themeToVariables,
|
||||
} from "@/themes";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
/** User preference: a theme ID (e.g. "dark", "nord") or "system" */
|
||||
theme: string;
|
||||
/** The active theme's ID after resolving "system" */
|
||||
themeId: string;
|
||||
/** The full active ThemeDefinition object */
|
||||
themeDefinition: ThemeDefinition;
|
||||
/** "light" or "dark" classification of the active theme */
|
||||
resolvedTheme: "light" | "dark";
|
||||
setTheme: (theme: Theme) => void;
|
||||
/** Set theme by ID or "system" */
|
||||
setTheme: (theme: string) => void;
|
||||
/** Quick toggle between "dark" and "light" themes */
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
@@ -15,105 +37,112 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = "mosaic-theme";
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
function getSystemThemeId(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function getStoredTheme(): Theme {
|
||||
function getStoredPreference(): string {
|
||||
if (typeof window === "undefined") return "system";
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
if (stored && (stored === "system" || isValidThemeId(stored))) {
|
||||
return stored;
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the resolved theme to the <html> element via data-theme attribute.
|
||||
* The default (no attribute or data-theme="dark") renders dark — dark is default.
|
||||
* Light theme requires data-theme="light".
|
||||
*/
|
||||
function applyThemeAttribute(resolved: "light" | "dark"): void {
|
||||
function resolveThemeId(preference: string): string {
|
||||
if (preference === "system") return getSystemThemeId();
|
||||
return preference;
|
||||
}
|
||||
|
||||
function applyThemeVariables(themeDef: ThemeDefinition): void {
|
||||
const root = document.documentElement;
|
||||
if (resolved === "light") {
|
||||
root.setAttribute("data-theme", "light");
|
||||
} else {
|
||||
// Remove the attribute so the default (dark) CSS variables apply.
|
||||
root.removeAttribute("data-theme");
|
||||
const vars = themeToVariables(themeDef);
|
||||
|
||||
for (const [prop, value] of Object.entries(vars)) {
|
||||
root.style.setProperty(prop, value);
|
||||
}
|
||||
|
||||
// Set data-theme attribute for CSS selectors that depend on light/dark
|
||||
root.setAttribute("data-theme", themeDef.isDark ? "dark" : "light");
|
||||
}
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
defaultTheme?: string;
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
}: ThemeProviderProps): React.JSX.Element {
|
||||
const [theme, setThemeState] = useState<Theme>(defaultTheme);
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("dark");
|
||||
const [preference, setPreference] = useState<string>(defaultTheme);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Initialize theme from storage on mount
|
||||
const themeId = useMemo(() => resolveThemeId(preference), [preference]);
|
||||
const themeDefinition = useMemo(() => getThemeOrDefault(themeId), [themeId]);
|
||||
const resolvedTheme = themeDefinition.isDark ? "dark" : "light";
|
||||
|
||||
// Initialize from storage on mount
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const storedTheme = getStoredTheme();
|
||||
const resolved = storedTheme === "system" ? getSystemTheme() : storedTheme;
|
||||
setThemeState(storedTheme);
|
||||
setResolvedTheme(resolved);
|
||||
applyThemeAttribute(resolved);
|
||||
const stored = getStoredPreference();
|
||||
setPreference(stored);
|
||||
|
||||
const id = resolveThemeId(stored);
|
||||
const def = getThemeOrDefault(id);
|
||||
applyThemeVariables(def);
|
||||
}, []);
|
||||
|
||||
// Apply theme via data-theme attribute on html element
|
||||
// Apply theme whenever preference changes (after mount)
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
applyThemeVariables(themeDefinition);
|
||||
}, [themeDefinition, mounted]);
|
||||
|
||||
const resolved = theme === "system" ? getSystemTheme() : theme;
|
||||
applyThemeAttribute(resolved);
|
||||
setResolvedTheme(resolved);
|
||||
}, [theme, mounted]);
|
||||
|
||||
// Listen for system theme changes
|
||||
// Listen for system theme changes when preference is "system"
|
||||
useEffect(() => {
|
||||
if (!mounted || theme !== "system") return;
|
||||
if (!mounted || preference !== "system") return;
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = (e: MediaQueryListEvent): void => {
|
||||
const resolved = e.matches ? "dark" : "light";
|
||||
setResolvedTheme(resolved);
|
||||
applyThemeAttribute(resolved);
|
||||
const id = e.matches ? "dark" : "light";
|
||||
const def = getThemeOrDefault(id);
|
||||
applyThemeVariables(def);
|
||||
// Force re-render by updating preference to trigger useMemo recalc
|
||||
setPreference("system");
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return (): void => {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
};
|
||||
}, [theme, mounted]);
|
||||
}, [preference, mounted]);
|
||||
|
||||
const setTheme = useCallback((newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem(STORAGE_KEY, newTheme);
|
||||
const setTheme = useCallback((newPreference: string) => {
|
||||
setPreference(newPreference);
|
||||
localStorage.setItem(STORAGE_KEY, newPreference);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
||||
}, [resolvedTheme, setTheme]);
|
||||
|
||||
// Prevent flash by not rendering until mounted
|
||||
// SSR placeholder — render children but with dark defaults
|
||||
if (!mounted) {
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
theme: defaultTheme,
|
||||
themeId: "dark",
|
||||
themeDefinition: darkTheme,
|
||||
resolvedTheme: "dark",
|
||||
setTheme: (): void => {
|
||||
// No-op during SSR
|
||||
/* no-op during SSR */
|
||||
},
|
||||
toggleTheme: (): void => {
|
||||
// No-op during SSR
|
||||
/* no-op during SSR */
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -123,7 +152,16 @@ export function ThemeProvider({
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggleTheme }}>
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
theme: preference,
|
||||
themeId,
|
||||
themeDefinition,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
|
||||
170
apps/web/src/themes/__tests__/registry.test.ts
Normal file
170
apps/web/src/themes/__tests__/registry.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { darkTheme } from "../dark";
|
||||
import { draculaTheme } from "../dracula";
|
||||
import { lightTheme } from "../light";
|
||||
import { nordTheme } from "../nord";
|
||||
import {
|
||||
DEFAULT_THEME_ID,
|
||||
getAllThemes,
|
||||
getDarkThemes,
|
||||
getLightThemes,
|
||||
getTheme,
|
||||
getThemeOrDefault,
|
||||
isValidThemeId,
|
||||
} from "../registry";
|
||||
import { solarizedDarkTheme } from "../solarized-dark";
|
||||
import type { ThemeColors, ThemeDefinition } from "../types";
|
||||
import { themeToVariables } from "../types";
|
||||
|
||||
const ALL_THEMES = [darkTheme, lightTheme, nordTheme, draculaTheme, solarizedDarkTheme];
|
||||
|
||||
const REQUIRED_COLOR_KEYS: (keyof ThemeColors)[] = [
|
||||
"bg-950",
|
||||
"bg-900",
|
||||
"bg-850",
|
||||
"surface-800",
|
||||
"surface-750",
|
||||
"border-700",
|
||||
"text-100",
|
||||
"text-300",
|
||||
"text-500",
|
||||
"blue-500",
|
||||
"blue-400",
|
||||
"red-500",
|
||||
"red-400",
|
||||
"purple-500",
|
||||
"purple-400",
|
||||
"teal-500",
|
||||
"teal-400",
|
||||
"amber-500",
|
||||
"amber-400",
|
||||
"pink-500",
|
||||
"emerald-500",
|
||||
"orange-500",
|
||||
"cyan-500",
|
||||
"indigo-500",
|
||||
];
|
||||
|
||||
describe("Theme Registry", () => {
|
||||
it("getAllThemes returns all 5 built-in themes", () => {
|
||||
const themes = getAllThemes();
|
||||
expect(themes).toHaveLength(5);
|
||||
expect(themes.map((t) => t.id)).toEqual(["dark", "light", "nord", "dracula", "solarized-dark"]);
|
||||
});
|
||||
|
||||
it("getTheme returns correct theme by id", () => {
|
||||
expect(getTheme("dark")).toBe(darkTheme);
|
||||
expect(getTheme("light")).toBe(lightTheme);
|
||||
expect(getTheme("nord")).toBe(nordTheme);
|
||||
expect(getTheme("dracula")).toBe(draculaTheme);
|
||||
expect(getTheme("solarized-dark")).toBe(solarizedDarkTheme);
|
||||
});
|
||||
|
||||
it("getTheme returns undefined for unknown id", () => {
|
||||
expect(getTheme("nonexistent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getThemeOrDefault falls back to dark theme", () => {
|
||||
expect(getThemeOrDefault("nonexistent")).toBe(darkTheme);
|
||||
expect(getThemeOrDefault("dark")).toBe(darkTheme);
|
||||
});
|
||||
|
||||
it("getDarkThemes returns only dark themes", () => {
|
||||
const dark = getDarkThemes();
|
||||
expect(dark.every((t) => t.isDark)).toBe(true);
|
||||
expect(dark).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("getLightThemes returns only light themes", () => {
|
||||
const light = getLightThemes();
|
||||
expect(light.every((t) => !t.isDark)).toBe(true);
|
||||
expect(light).toHaveLength(1);
|
||||
expect(light[0]?.id).toBe("light");
|
||||
});
|
||||
|
||||
it("isValidThemeId validates correctly", () => {
|
||||
expect(isValidThemeId("dark")).toBe(true);
|
||||
expect(isValidThemeId("light")).toBe(true);
|
||||
expect(isValidThemeId("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("DEFAULT_THEME_ID is dark", () => {
|
||||
expect(DEFAULT_THEME_ID).toBe("dark");
|
||||
});
|
||||
|
||||
it("getAllThemes returns a copy, not the internal array", () => {
|
||||
const a = getAllThemes();
|
||||
const b = getAllThemes();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Theme Definitions", () => {
|
||||
it.each(ALL_THEMES.map((t) => [t.id, t] as const))(
|
||||
"%s has all required fields",
|
||||
(_id, theme: ThemeDefinition) => {
|
||||
expect(theme.id).toBeTruthy();
|
||||
expect(theme.name).toBeTruthy();
|
||||
expect(theme.description).toBeTruthy();
|
||||
expect(theme.author).toBeTruthy();
|
||||
expect(typeof theme.isDark).toBe("boolean");
|
||||
expect(theme.colorPreview).toHaveLength(5);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(ALL_THEMES.map((t) => [t.id, t] as const))(
|
||||
"%s has all required color tokens",
|
||||
(_id, theme: ThemeDefinition) => {
|
||||
for (const key of REQUIRED_COLOR_KEYS) {
|
||||
expect(theme.colors[key], `missing color: ${key}`).toBeTruthy();
|
||||
expect(theme.colors[key]).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each(ALL_THEMES.map((t) => [t.id, t] as const))(
|
||||
"%s has valid shadow definitions",
|
||||
(_id, theme: ThemeDefinition) => {
|
||||
expect(theme.shadows.sm).toBeTruthy();
|
||||
expect(theme.shadows.md).toBeTruthy();
|
||||
expect(theme.shadows.lg).toBeTruthy();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(ALL_THEMES.map((t) => [t.id, t] as const))(
|
||||
"%s colorPreview values are valid hex colors",
|
||||
(_id, theme: ThemeDefinition) => {
|
||||
for (const color of theme.colorPreview) {
|
||||
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("all theme IDs are unique", () => {
|
||||
const ids = ALL_THEMES.map((t) => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("themeToVariables", () => {
|
||||
it("maps color tokens to --ms-* CSS variables", () => {
|
||||
const vars = themeToVariables(darkTheme);
|
||||
expect(vars["--ms-bg-900"]).toBe("#0f141d");
|
||||
expect(vars["--ms-blue-500"]).toBe("#2f80ff");
|
||||
expect(vars["--ms-text-100"]).toBe("#eef3ff");
|
||||
});
|
||||
|
||||
it("includes shadow variables", () => {
|
||||
const vars = themeToVariables(darkTheme);
|
||||
expect(vars["--shadow-sm"]).toBe(darkTheme.shadows.sm);
|
||||
expect(vars["--shadow-md"]).toBe(darkTheme.shadows.md);
|
||||
expect(vars["--shadow-lg"]).toBe(darkTheme.shadows.lg);
|
||||
});
|
||||
|
||||
it("generates correct number of variables (24 colors + 3 shadows)", () => {
|
||||
const vars = themeToVariables(darkTheme);
|
||||
expect(Object.keys(vars)).toHaveLength(27);
|
||||
});
|
||||
});
|
||||
41
apps/web/src/themes/dark.ts
Normal file
41
apps/web/src/themes/dark.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ThemeDefinition } from "./types";
|
||||
|
||||
export const darkTheme: ThemeDefinition = {
|
||||
id: "dark",
|
||||
name: "Dark",
|
||||
description: "Default dark theme — deep navy with vibrant accents",
|
||||
author: "Mosaic Stack",
|
||||
isDark: true,
|
||||
colorPreview: ["#0f141d", "#1b2331", "#eef3ff", "#2f80ff", "#8b5cf6"],
|
||||
colors: {
|
||||
"bg-950": "#080b12",
|
||||
"bg-900": "#0f141d",
|
||||
"bg-850": "#151b26",
|
||||
"surface-800": "#1b2331",
|
||||
"surface-750": "#232d3f",
|
||||
"border-700": "#2f3b52",
|
||||
"text-100": "#eef3ff",
|
||||
"text-300": "#c5d0e6",
|
||||
"text-500": "#8f9db7",
|
||||
"blue-500": "#2f80ff",
|
||||
"blue-400": "#56a0ff",
|
||||
"red-500": "#e5484d",
|
||||
"red-400": "#f06a6f",
|
||||
"purple-500": "#8b5cf6",
|
||||
"purple-400": "#a78bfa",
|
||||
"teal-500": "#14b8a6",
|
||||
"teal-400": "#2dd4bf",
|
||||
"amber-500": "#f59e0b",
|
||||
"amber-400": "#fbbf24",
|
||||
"pink-500": "#ec4899",
|
||||
"emerald-500": "#10b981",
|
||||
"orange-500": "#f97316",
|
||||
"cyan-500": "#06b6d4",
|
||||
"indigo-500": "#6366f1",
|
||||
},
|
||||
shadows: {
|
||||
sm: "0 1px 2px 0 rgb(0 0 0 / 0.3)",
|
||||
md: "0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3)",
|
||||
lg: "0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4)",
|
||||
},
|
||||
};
|
||||
45
apps/web/src/themes/dracula.ts
Normal file
45
apps/web/src/themes/dracula.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ThemeDefinition } from "./types";
|
||||
|
||||
/**
|
||||
* Dracula theme — dark theme with vibrant neon accents.
|
||||
* Based on https://draculatheme.com/
|
||||
*/
|
||||
export const draculaTheme: ThemeDefinition = {
|
||||
id: "dracula",
|
||||
name: "Dracula",
|
||||
description: "Dark theme with vibrant, neon-inspired accents",
|
||||
author: "Zeno Rocha",
|
||||
isDark: true,
|
||||
colorPreview: ["#282a36", "#44475a", "#f8f8f2", "#7b93db", "#ff79c6"],
|
||||
colors: {
|
||||
"bg-950": "#1e1f29",
|
||||
"bg-900": "#282a36",
|
||||
"bg-850": "#2d303d",
|
||||
"surface-800": "#343746",
|
||||
"surface-750": "#44475a",
|
||||
"border-700": "#555a78",
|
||||
"text-100": "#f8f8f2",
|
||||
"text-300": "#d4d4cd",
|
||||
"text-500": "#6272a4",
|
||||
"blue-500": "#7b93db",
|
||||
"blue-400": "#99aee6",
|
||||
"red-500": "#ff5555",
|
||||
"red-400": "#ff7777",
|
||||
"purple-500": "#bd93f9",
|
||||
"purple-400": "#caa9fa",
|
||||
"teal-500": "#50fa7b",
|
||||
"teal-400": "#69ff93",
|
||||
"amber-500": "#f1fa8c",
|
||||
"amber-400": "#f5fca6",
|
||||
"pink-500": "#ff79c6",
|
||||
"emerald-500": "#50fa7b",
|
||||
"orange-500": "#ffb86c",
|
||||
"cyan-500": "#8be9fd",
|
||||
"indigo-500": "#8b8fe8",
|
||||
},
|
||||
shadows: {
|
||||
sm: "0 1px 2px 0 rgb(0 0 0 / 0.3)",
|
||||
md: "0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3)",
|
||||
lg: "0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4)",
|
||||
},
|
||||
};
|
||||
18
apps/web/src/themes/index.ts
Normal file
18
apps/web/src/themes/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type { ThemeColors, ThemeDefinition, ThemeShadows, ThemeColorKey } from "./types";
|
||||
export { themeToVariables } from "./types";
|
||||
|
||||
export { darkTheme } from "./dark";
|
||||
export { lightTheme } from "./light";
|
||||
export { nordTheme } from "./nord";
|
||||
export { draculaTheme } from "./dracula";
|
||||
export { solarizedDarkTheme } from "./solarized-dark";
|
||||
|
||||
export {
|
||||
getAllThemes,
|
||||
getTheme,
|
||||
getThemeOrDefault,
|
||||
getDarkThemes,
|
||||
getLightThemes,
|
||||
isValidThemeId,
|
||||
DEFAULT_THEME_ID,
|
||||
} from "./registry";
|
||||
41
apps/web/src/themes/light.ts
Normal file
41
apps/web/src/themes/light.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ThemeDefinition } from "./types";
|
||||
|
||||
export const lightTheme: ThemeDefinition = {
|
||||
id: "light",
|
||||
name: "Light",
|
||||
description: "Clean light theme — soft blues with crisp contrast",
|
||||
author: "Mosaic Stack",
|
||||
isDark: false,
|
||||
colorPreview: ["#f0f4fc", "#dde4f2", "#0f141d", "#2f80ff", "#8b5cf6"],
|
||||
colors: {
|
||||
"bg-950": "#f8faff",
|
||||
"bg-900": "#f0f4fc",
|
||||
"bg-850": "#e8edf8",
|
||||
"surface-800": "#dde4f2",
|
||||
"surface-750": "#d0d9ec",
|
||||
"border-700": "#b8c4de",
|
||||
"text-100": "#0f141d",
|
||||
"text-300": "#2f3b52",
|
||||
"text-500": "#5a6a87",
|
||||
"blue-500": "#2f80ff",
|
||||
"blue-400": "#56a0ff",
|
||||
"red-500": "#e5484d",
|
||||
"red-400": "#f06a6f",
|
||||
"purple-500": "#8b5cf6",
|
||||
"purple-400": "#a78bfa",
|
||||
"teal-500": "#14b8a6",
|
||||
"teal-400": "#2dd4bf",
|
||||
"amber-500": "#f59e0b",
|
||||
"amber-400": "#fbbf24",
|
||||
"pink-500": "#ec4899",
|
||||
"emerald-500": "#10b981",
|
||||
"orange-500": "#f97316",
|
||||
"cyan-500": "#06b6d4",
|
||||
"indigo-500": "#6366f1",
|
||||
},
|
||||
shadows: {
|
||||
sm: "0 1px 2px 0 rgb(0 0 0 / 0.05), 0 1px 3px 0 rgb(0 0 0 / 0.05)",
|
||||
md: "0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06)",
|
||||
lg: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.08)",
|
||||
},
|
||||
};
|
||||
45
apps/web/src/themes/nord.ts
Normal file
45
apps/web/src/themes/nord.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ThemeDefinition } from "./types";
|
||||
|
||||
/**
|
||||
* Nord theme — Arctic, north-bluish palette.
|
||||
* Based on https://www.nordtheme.com/
|
||||
*/
|
||||
export const nordTheme: ThemeDefinition = {
|
||||
id: "nord",
|
||||
name: "Nord",
|
||||
description: "Arctic, north-bluish color palette inspired by the beauty of the arctic",
|
||||
author: "Arctic Ice Studio",
|
||||
isDark: true,
|
||||
colorPreview: ["#2e3440", "#3b4252", "#eceff4", "#5e81ac", "#b48ead"],
|
||||
colors: {
|
||||
"bg-950": "#242933",
|
||||
"bg-900": "#2e3440",
|
||||
"bg-850": "#333a47",
|
||||
"surface-800": "#3b4252",
|
||||
"surface-750": "#434c5e",
|
||||
"border-700": "#4c566a",
|
||||
"text-100": "#eceff4",
|
||||
"text-300": "#d8dee9",
|
||||
"text-500": "#7b88a1",
|
||||
"blue-500": "#5e81ac",
|
||||
"blue-400": "#81a1c1",
|
||||
"red-500": "#bf616a",
|
||||
"red-400": "#d08787",
|
||||
"purple-500": "#b48ead",
|
||||
"purple-400": "#c4a5bf",
|
||||
"teal-500": "#8fbcbb",
|
||||
"teal-400": "#88c0d0",
|
||||
"amber-500": "#ebcb8b",
|
||||
"amber-400": "#f0d8a8",
|
||||
"pink-500": "#c97fba",
|
||||
"emerald-500": "#a3be8c",
|
||||
"orange-500": "#d08770",
|
||||
"cyan-500": "#88c0d0",
|
||||
"indigo-500": "#7b88a1",
|
||||
},
|
||||
shadows: {
|
||||
sm: "0 1px 2px 0 rgb(0 0 0 / 0.25)",
|
||||
md: "0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.25)",
|
||||
lg: "0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.35)",
|
||||
},
|
||||
};
|
||||
50
apps/web/src/themes/registry.ts
Normal file
50
apps/web/src/themes/registry.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { darkTheme } from "./dark";
|
||||
import { draculaTheme } from "./dracula";
|
||||
import { lightTheme } from "./light";
|
||||
import { nordTheme } from "./nord";
|
||||
import { solarizedDarkTheme } from "./solarized-dark";
|
||||
import type { ThemeDefinition } from "./types";
|
||||
|
||||
/** All built-in themes, ordered for display */
|
||||
const builtInThemes: ThemeDefinition[] = [
|
||||
darkTheme,
|
||||
lightTheme,
|
||||
nordTheme,
|
||||
draculaTheme,
|
||||
solarizedDarkTheme,
|
||||
];
|
||||
|
||||
const themeMap = new Map<string, ThemeDefinition>(builtInThemes.map((t) => [t.id, t]));
|
||||
|
||||
/** Default theme when no preference is set */
|
||||
export const DEFAULT_THEME_ID = "dark";
|
||||
|
||||
/** Get all registered themes */
|
||||
export function getAllThemes(): ThemeDefinition[] {
|
||||
return [...builtInThemes];
|
||||
}
|
||||
|
||||
/** Get a theme by ID, or undefined if not found */
|
||||
export function getTheme(id: string): ThemeDefinition | undefined {
|
||||
return themeMap.get(id);
|
||||
}
|
||||
|
||||
/** Get a theme by ID, falling back to the default dark theme */
|
||||
export function getThemeOrDefault(id: string): ThemeDefinition {
|
||||
return themeMap.get(id) ?? darkTheme;
|
||||
}
|
||||
|
||||
/** Get only dark themes */
|
||||
export function getDarkThemes(): ThemeDefinition[] {
|
||||
return builtInThemes.filter((t) => t.isDark);
|
||||
}
|
||||
|
||||
/** Get only light themes */
|
||||
export function getLightThemes(): ThemeDefinition[] {
|
||||
return builtInThemes.filter((t) => !t.isDark);
|
||||
}
|
||||
|
||||
/** Check if a theme ID is valid */
|
||||
export function isValidThemeId(id: string): boolean {
|
||||
return themeMap.has(id);
|
||||
}
|
||||
45
apps/web/src/themes/solarized-dark.ts
Normal file
45
apps/web/src/themes/solarized-dark.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ThemeDefinition } from "./types";
|
||||
|
||||
/**
|
||||
* Solarized Dark theme — precision colors for machines and people.
|
||||
* Based on https://ethanschoonover.com/solarized/
|
||||
*/
|
||||
export const solarizedDarkTheme: ThemeDefinition = {
|
||||
id: "solarized-dark",
|
||||
name: "Solarized Dark",
|
||||
description: "Precision color palette with selective contrast relationships",
|
||||
author: "Ethan Schoonover",
|
||||
isDark: true,
|
||||
colorPreview: ["#002b36", "#073642", "#fdf6e3", "#268bd2", "#6c71c4"],
|
||||
colors: {
|
||||
"bg-950": "#001e26",
|
||||
"bg-900": "#002b36",
|
||||
"bg-850": "#04313d",
|
||||
"surface-800": "#073642",
|
||||
"surface-750": "#174452",
|
||||
"border-700": "#2a5565",
|
||||
"text-100": "#fdf6e3",
|
||||
"text-300": "#93a1a1",
|
||||
"text-500": "#657b83",
|
||||
"blue-500": "#268bd2",
|
||||
"blue-400": "#4ba2de",
|
||||
"red-500": "#dc322f",
|
||||
"red-400": "#e35855",
|
||||
"purple-500": "#6c71c4",
|
||||
"purple-400": "#8b8fd3",
|
||||
"teal-500": "#2aa198",
|
||||
"teal-400": "#47b5ad",
|
||||
"amber-500": "#b58900",
|
||||
"amber-400": "#cba020",
|
||||
"pink-500": "#d33682",
|
||||
"emerald-500": "#859900",
|
||||
"orange-500": "#cb4b16",
|
||||
"cyan-500": "#36bcb3",
|
||||
"indigo-500": "#4b66c4",
|
||||
},
|
||||
shadows: {
|
||||
sm: "0 1px 2px 0 rgb(0 0 0 / 0.35)",
|
||||
md: "0 4px 6px -1px rgb(0 0 0 / 0.45), 0 2px 4px -2px rgb(0 0 0 / 0.35)",
|
||||
lg: "0 10px 15px -3px rgb(0 0 0 / 0.55), 0 4px 6px -4px rgb(0 0 0 / 0.45)",
|
||||
},
|
||||
};
|
||||
99
apps/web/src/themes/types.ts
Normal file
99
apps/web/src/themes/types.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Mosaic Theme System — Type Definitions
|
||||
*
|
||||
* Each theme provides a complete set of CSS variable overrides.
|
||||
* The token names map to `--ms-{key}` CSS variables in globals.css.
|
||||
*/
|
||||
|
||||
export interface ThemeColors {
|
||||
/** Deepest background (e.g. behind modals) */
|
||||
"bg-950": string;
|
||||
/** Main page background */
|
||||
"bg-900": string;
|
||||
/** Elevated background (sidebar, panels) */
|
||||
"bg-850": string;
|
||||
/** Card/panel surface */
|
||||
"surface-800": string;
|
||||
/** Hover/secondary surface */
|
||||
"surface-750": string;
|
||||
/** Border color */
|
||||
"border-700": string;
|
||||
|
||||
/** Primary text */
|
||||
"text-100": string;
|
||||
/** Secondary text */
|
||||
"text-300": string;
|
||||
/** Muted/tertiary text */
|
||||
"text-500": string;
|
||||
|
||||
/** Primary accent */
|
||||
"blue-500": string;
|
||||
/** Primary accent lighter */
|
||||
"blue-400": string;
|
||||
/** Danger/error */
|
||||
"red-500": string;
|
||||
/** Danger lighter */
|
||||
"red-400": string;
|
||||
/** Purple accent */
|
||||
"purple-500": string;
|
||||
/** Purple lighter */
|
||||
"purple-400": string;
|
||||
/** Success/teal */
|
||||
"teal-500": string;
|
||||
/** Success lighter */
|
||||
"teal-400": string;
|
||||
/** Warning/amber */
|
||||
"amber-500": string;
|
||||
/** Warning lighter */
|
||||
"amber-400": string;
|
||||
/** Pink accent */
|
||||
"pink-500": string;
|
||||
/** Emerald accent */
|
||||
"emerald-500": string;
|
||||
/** Orange accent */
|
||||
"orange-500": string;
|
||||
/** Cyan accent */
|
||||
"cyan-500": string;
|
||||
/** Indigo accent */
|
||||
"indigo-500": string;
|
||||
}
|
||||
|
||||
export interface ThemeShadows {
|
||||
sm: string;
|
||||
md: string;
|
||||
lg: string;
|
||||
}
|
||||
|
||||
export interface ThemeDefinition {
|
||||
/** Unique identifier (used in localStorage + UserPreference) */
|
||||
id: string;
|
||||
/** Display name */
|
||||
name: string;
|
||||
/** Short description */
|
||||
description: string;
|
||||
/** Theme author/credit */
|
||||
author: string;
|
||||
/** Whether this is a dark-mode theme */
|
||||
isDark: boolean;
|
||||
/** Five representative colors for preview swatches [bg, surface, text, primary, accent] */
|
||||
colorPreview: [string, string, string, string, string];
|
||||
/** Color token overrides (maps to --ms-{key} CSS variables) */
|
||||
colors: ThemeColors;
|
||||
/** Shadow overrides */
|
||||
shadows: ThemeShadows;
|
||||
}
|
||||
|
||||
/** The color token keys that map to --ms-{key} CSS variables */
|
||||
export type ThemeColorKey = keyof ThemeColors;
|
||||
|
||||
/** All CSS variable names a theme can set (--ms-{colorKey} + --shadow-{sm|md|lg}) */
|
||||
export function themeToVariables(theme: ThemeDefinition): Record<string, string> {
|
||||
const vars: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(theme.colors) as [string, string][]) {
|
||||
vars[`--ms-${key}`] = value;
|
||||
}
|
||||
vars["--shadow-sm"] = theme.shadows.sm;
|
||||
vars["--shadow-md"] = theme.shadows.md;
|
||||
vars["--shadow-lg"] = theme.shadows.lg;
|
||||
return vars;
|
||||
}
|
||||
@@ -1,68 +1,72 @@
|
||||
# Mission Manifest — Mosaic Stack Go-Live MVP
|
||||
# Mission Manifest — MS19 Chat & Terminal System
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** mosaic-stack-go-live-mvp-20260222
|
||||
**Statement:** Ship Mosaic Stack MVP: operational dashboard with theming, task ingestion, one visible agent cycle, deployed and smoke-tested. Unblocks SagePHR, DYOR, Calibr, and downstream projects.
|
||||
**Phase:** Execution
|
||||
**Current Milestone:** phase-2 (Task Ingestion Pipeline)
|
||||
**Progress:** 1 / 4 milestones
|
||||
**Status:** active
|
||||
**Last Updated:** 2026-02-23 00:20 UTC
|
||||
**ID:** ms19-chat-terminal-20260225
|
||||
**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
|
||||
**Current Milestone:** MS19-ChatTerminal
|
||||
**Progress:** 0 / 1 milestones
|
||||
**Status:** planning
|
||||
**Last Updated:** 2026-02-25T20:00Z
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. Dashboard loads reliably at mosaic.woltje.com with theming system operational
|
||||
2. Task/status ingestion: create/update flow persists records visible in UI
|
||||
3. One agent job runs from queued to completion/failure with status transitions visible
|
||||
4. Stack deployed via Coolify, auth + dashboard reachable, smoke checks passing
|
||||
5. Design reference: `mosaic-stack-website/docs/designs/round-5/claude/01/dashboard.html`
|
||||
1. Terminal panel has real xterm.js with PTY backend via WebSocket
|
||||
2. Terminal supports multiple named sessions (create/close/rename tabs)
|
||||
3. Terminal sessions persist in PostgreSQL and recover on reconnect
|
||||
4. Chat streaming renders tokens in real-time via SSE
|
||||
5. Master chat sidebar accessible from any page (Cmd+Shift+J / Cmd+K)
|
||||
6. Master chat supports model selection, temperature, conversation management
|
||||
7. Project-level chat can trigger orchestrator actions (/spawn, /status, /jobs)
|
||||
8. Agent output from orchestrator viewable in terminal tabs
|
||||
9. All features support all 5 themes (Dark, Light, Nord, Dracula, Solarized)
|
||||
10. Lint, typecheck, and tests pass
|
||||
11. Deployed and smoke-tested at mosaic.woltje.com
|
||||
|
||||
## Prior Work (MS15-DashboardShell — Complete)
|
||||
## Existing Infrastructure
|
||||
|
||||
The Feb 22 agent completed the dashboard shell foundation:
|
||||
Key components already built that MS19 builds upon:
|
||||
|
||||
- PR #451: Design System & App Shell (tokens, layout, sidebar, topbar, responsive, spinner)
|
||||
- PR #452: Shared Components & Terminal Panel (ui token alignment, card/badge/button, metrics, terminal)
|
||||
- PR #453: Dashboard Page (widget grid, activity feed, command palette, notifications)
|
||||
- PR #454: Design system reference docs
|
||||
|
||||
This mission continues from that foundation.
|
||||
| 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
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------- | -------------------------- | ----------- | ---------------------- | ----- | ---------- | ---------- |
|
||||
| 1 | phase-1 | Dashboard Polish + Theming | completed | feat/phase-1-polish | #457 | 2026-02-22 | 2026-02-23 |
|
||||
| 2 | phase-2 | Task Ingestion Pipeline | in-progress | feat/phase-2-ingestion | #459 | 2026-02-23 | — |
|
||||
| 3 | phase-3 | Agent Cycle Visibility | pending | — | — | — | — |
|
||||
| 4 | phase-4 | Deploy + Smoke Test | pending | — | — | — | — |
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ---- | ---------------------- | -------- | ------------------------- | ------------------------ | ---------- | --------- |
|
||||
| 1 | MS19 | Chat & Terminal System | planning | per-task feature branches | #508,#509,#510,#511,#512 | 2026-02-25 | — |
|
||||
|
||||
## Deployment
|
||||
|
||||
| Target | URL | Method |
|
||||
| ---------- | ---------------------- | ------------------- |
|
||||
| Production | mosaic.woltje.com | Coolify (10.1.1.44) |
|
||||
| Auth | auth.diversecanvas.com | Authentik SSO |
|
||||
| Registry | git.mosaicstack.dev | Gitea Packages |
|
||||
| Target | URL | Method |
|
||||
| --------- | ----------------- | --------------------------- |
|
||||
| Portainer | mosaic.woltje.com | CI/CD pipeline (Woodpecker) |
|
||||
|
||||
## Token Budget
|
||||
|
||||
| Metric | Value |
|
||||
| ------ | ------ |
|
||||
| Budget | — |
|
||||
| Used | 0 |
|
||||
| Mode | normal |
|
||||
| Metric | Value |
|
||||
| ------ | ----------------- |
|
||||
| Budget | ~300K (estimated) |
|
||||
| Used | ~0K |
|
||||
| Mode | normal |
|
||||
|
||||
## Session History
|
||||
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | ------- | ---------------- | -------- | ------------ | --------- |
|
||||
| S1 | Claude | 2026-02-22 17:50 | — | — | — |
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | --------------- | ----------------- | -------- | ------------ | ------------------- |
|
||||
| S1 | Claude Opus 4.6 | 2026-02-25T20:00Z | — | — | Planning (PLAN-001) |
|
||||
|
||||
## Scratchpad
|
||||
|
||||
Path: `docs/scratchpads/mosaic-stack-go-live-mvp-20260222.md`
|
||||
Path: `docs/scratchpads/ms19-chat-terminal-20260225.md`
|
||||
|
||||
431
docs/PRD.md
431
docs/PRD.md
@@ -24,49 +24,121 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
||||
9. Build global terminal, project chat, and master chat session
|
||||
10. Configure telemetry with opt-out support
|
||||
|
||||
## Completed Work
|
||||
|
||||
### MS15-DashboardShell (v0.0.15) — Complete
|
||||
|
||||
Design system + app shell + dashboard page. PRs #451-454.
|
||||
|
||||
- CSS design token system (colors, fonts, spacing, radii)
|
||||
- App shell layout: collapsible sidebar + full-width header + main content
|
||||
- Sidebar navigation with groups, icons, badges, active states, collapse/expand
|
||||
- Responsive layout with hamburger at small breakpoints
|
||||
- Light/dark theme matching reference design
|
||||
- Mosaic logo spinner as global loading indicator
|
||||
- Shared component updates in packages/ui
|
||||
- Dashboard page: metrics strip, orchestrator sessions, quick actions, activity feed, token budget
|
||||
- Grain overlay texture
|
||||
|
||||
### Go-Live MVP (v0.1.0) — Complete
|
||||
|
||||
Dashboard polish, task ingestion pipeline, agent cycle visibility, deploy + smoke test. PRs #458, #460, #462, #464.
|
||||
|
||||
- Fixed broken test suites and removed legacy unused widgets
|
||||
- Visual + theme polish across all components
|
||||
- Dashboard summary API endpoint (aggregated task counts, project counts, activity, jobs)
|
||||
- Dashboard widgets wired to real API data (ActivityFeed, DashboardMetrics, OrchestratorSessions)
|
||||
- WebSocket emits for job status/progress/step events
|
||||
- Dashboard auto-refresh with polling + progress bars + step status indicators
|
||||
- Deployed to mosaic.woltje.com, auth working via Authentik
|
||||
- Release tag v0.1.0
|
||||
|
||||
### MS16+MS17-PagesDataIntegration (v0.1.1) — Complete
|
||||
|
||||
All pages built + wired to real API data. PRs #470-484 (15 PRs). Issues #466-469.
|
||||
|
||||
- Custom 404 pages (global + authenticated route groups)
|
||||
- Settings root page with 4 category cards
|
||||
- Tasks, Calendar, Knowledge pages wired to real API (238+ lines mock data removed)
|
||||
- Projects list page with create/delete dialogs
|
||||
- Project Workspace page with tabbed view (Tasks, Agent Sessions, Settings)
|
||||
- Kanban board with drag-and-drop (@hello-pangea/dnd), 5 status columns, optimistic updates
|
||||
- File Manager page with list/grid views, search, create/delete
|
||||
- Logs & Telemetry page with auto-refresh, expandable rows, filters
|
||||
- Profile page with user info and preferences
|
||||
- All 5125 tests passing, CI pipeline #585 green
|
||||
- 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
|
||||
|
||||
### In Scope (Milestone 0.0.15 — Dashboard Shell & Design System)
|
||||
### In Scope (MS16+MS17 — Pages & Data Integration)
|
||||
|
||||
1. CSS design token system overhaul (colors, fonts, spacing, radii from dashboard.html)
|
||||
2. App shell layout: sidebar + full-width header + main content area
|
||||
3. Full-width header with logo, search, system status, terminal toggle, notifications, theme toggle, user avatar dropdown
|
||||
4. Collapsible sidebar with nav groups, icons, badges, active states, collapse/expand button
|
||||
5. Responsive layout with hamburger button at small breakpoints, sidebar hidden by default at mobile
|
||||
6. Light/dark theme matching the reference design
|
||||
7. Mosaic logo icon as global loading spinner
|
||||
8. Shared component updates in packages/ui (Card, Badge, Button, Dot, MetricsStrip, ProgressBar, FilterTabs, SectionHeader, Table, LogLine, Terminal panel)
|
||||
9. Dashboard page: metrics strip, active orchestrator sessions, quick actions, activity feed, token budget
|
||||
10. Grain overlay texture from reference design
|
||||
This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) are combined because the backend API modules already exist — the work is primarily frontend page creation and API wiring.
|
||||
|
||||
1. Projects list page with CRUD (wire to existing `/api/projects`)
|
||||
2. Project workspace/detail page (wire to `/api/projects/:id`, `/api/tasks`, `/api/runner-jobs`)
|
||||
3. Kanban board page with status-based columns (wire to existing `/api/tasks`)
|
||||
4. File Manager page with tree/list view and CRUD (wire to existing `/api/knowledge`)
|
||||
5. Logs & Telemetry page with log viewer and filtering (wire to `/api/runner-jobs`, job steps, events)
|
||||
6. Settings root/index page linking to existing subpages
|
||||
7. Custom 404 page for unknown routes
|
||||
8. Wire `/tasks` page to real API data (currently mock)
|
||||
9. Wire `/calendar` page to real API data (currently mock)
|
||||
10. Wire `/knowledge` pages to real API data (currently mock)
|
||||
|
||||
### In Scope (Future Milestones — Documented for Planning)
|
||||
|
||||
11. Additional pages: Projects, Workspace, Kanban, File Manager, Logs & Telemetry, Settings, Profile
|
||||
12. Theme system with installable theme packages
|
||||
13. Widget system with installable widget packages, customizable sizes
|
||||
14. Global terminal (project/orchestrator level, smart)
|
||||
15. Project-level orchestrator chat
|
||||
16. Master chat session (collapsible sidebar/slideout, always available)
|
||||
17. Settings page for ALL environment variables, dynamically configurable via webUI
|
||||
18. Multi-tenant configuration with admin user management
|
||||
19. Team management with shared data spaces and chat rooms
|
||||
20. RBAC for file access, resources, models
|
||||
21. Federation: master-master and master-slave with key exchange
|
||||
22. Federation testing: 3 instances on Coolify (woltje.com domain)
|
||||
23. Agent task mapping configuration (system-level defaults, user-level overrides)
|
||||
24. Telemetry: opt-out, customizable endpoint, sanitized data
|
||||
25. File manager with WYSIWYG editing (system/user/project levels)
|
||||
26. User-level and project-level Kanban with filtering
|
||||
27. Break-glass authentication user
|
||||
28. Playwright E2E tests for all pages
|
||||
29. API documentation via Swagger
|
||||
30. Backend endpoints for all dashboard data
|
||||
11. Theme system with installable theme packages (MS18)
|
||||
12. Widget system with installable widget packages, customizable sizes (MS18)
|
||||
13. Global terminal: project/orchestrator level, smart (MS19)
|
||||
14. Project-level orchestrator chat (MS19)
|
||||
15. Master chat session: collapsible sidebar/slideout, always available (MS19)
|
||||
16. Settings page for ALL environment variables, dynamically configurable via webUI (MS20)
|
||||
17. Multi-tenant configuration with admin user management (MS20)
|
||||
18. Team management with shared data spaces and chat rooms (MS20)
|
||||
19. RBAC for file access, resources, models (MS20)
|
||||
20. Federation: master-master and master-slave with key exchange (MS21)
|
||||
21. Federation testing: 3 instances on Portainer (woltje.com domain) (MS21)
|
||||
22. Agent task mapping configuration: system-level defaults, user-level overrides (MS22)
|
||||
23. Telemetry: opt-out, customizable endpoint, sanitized data (MS22)
|
||||
24. File manager with WYSIWYG editing: system/user/project levels (MS18)
|
||||
25. User-level and project-level Kanban with filtering (MS18)
|
||||
26. Break-glass authentication user (MS20)
|
||||
27. Playwright E2E tests for all pages (MS23)
|
||||
28. API documentation via Swagger (MS23)
|
||||
29. Backend endpoints for all dashboard data (MS17 — already complete for existing modules)
|
||||
30. Profile page linked from user card (MS16)
|
||||
|
||||
### Out of Scope
|
||||
|
||||
1. Mobile native app
|
||||
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)
|
||||
|
||||
## User/Stakeholder Requirements
|
||||
@@ -74,7 +146,7 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
||||
1. The `jarvis` user must be able to log into mosaic.woltje.com via Authentik as administrator with access to all pages
|
||||
2. A standard `jarvis-user` must operate at a lower permission level
|
||||
3. A break-glass user must have access without Authentik authentication
|
||||
4. All pages must be navigable without errors
|
||||
4. All pages must be navigable without errors (no 404s from sidebar links)
|
||||
5. Light and dark themes must work across all pages and components
|
||||
6. Sidebar must be collapsible with open/close button; hidden by default at small breakpoints
|
||||
7. Hamburger button visible at lower breakpoints for sidebar control
|
||||
@@ -89,12 +161,14 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
||||
- Dark theme as default (`:root`), light theme via `[data-theme="light"]`
|
||||
- Fonts: Outfit (body), Fira Code (monospace)
|
||||
- All components must use design tokens, never hardcoded colors
|
||||
- **Status: COMPLETE (MS15)**
|
||||
|
||||
### FR-002: App Shell Layout
|
||||
|
||||
- CSS Grid: sidebar column + header row + main content
|
||||
- Full-width header spanning above sidebar and content
|
||||
- ASSUMPTION: Header spans full width including above sidebar area. The logo is in the header, not the sidebar. Rationale: User explicitly stated "The logo will NOT be part of the sidebar."
|
||||
- **Status: COMPLETE (MS15)**
|
||||
|
||||
### FR-003: Sidebar Navigation
|
||||
|
||||
@@ -103,6 +177,7 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
||||
- Active state indicator (left border accent)
|
||||
- User card in footer with avatar, name, role, online status
|
||||
- ASSUMPTION: Sidebar footer user card navigates to Profile page. Rationale: Matches reference design behavior.
|
||||
- **Status: COMPLETE (MS15+MS16) — Profile page added in PR #482.**
|
||||
|
||||
### FR-004: Header/Topbar
|
||||
|
||||
@@ -113,6 +188,7 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
||||
- Notification bell with badge
|
||||
- Theme toggle (sun/moon icon)
|
||||
- User avatar button with dropdown (Profile, Account Settings, Sign Out)
|
||||
- **Status: COMPLETE (MS15)**
|
||||
|
||||
### FR-005: Responsive Design
|
||||
|
||||
@@ -120,6 +196,7 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
||||
- Below md: sidebar hidden, hamburger button in header
|
||||
- md-lg: sidebar can be toggled
|
||||
- lg+: sidebar visible by default
|
||||
- **Status: COMPLETE (MS15)**
|
||||
|
||||
### FR-006: Dashboard Page
|
||||
|
||||
@@ -128,28 +205,117 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
||||
- Quick Actions 2x2 grid
|
||||
- Activity Feed sidebar card
|
||||
- Token Budget sidebar card with progress bars
|
||||
- Wired to real API via `/api/dashboard/summary`
|
||||
- **Status: COMPLETE (Go-Live MVP)**
|
||||
|
||||
### FR-007: Loading Spinner
|
||||
|
||||
- Mosaic logo icon (4 corner squares + center circle) with CSS rotation animation
|
||||
- Used as global loading indicator across all pages
|
||||
- Available as a shared component
|
||||
- **Status: COMPLETE (MS15)**
|
||||
|
||||
### FR-008: Theme System (Future Milestone)
|
||||
### FR-008: Projects Page (MS16)
|
||||
|
||||
- Support multiple themes beyond default dark/light
|
||||
- Themes are installable packages from Mosaic Stack repo
|
||||
- Theme installation and selection from Settings page
|
||||
- ASSUMPTION: Initial implementation supports dark/light from reference design. Multi-theme package system is a future milestone. Rationale: Foundation must be solid before extensibility.
|
||||
- Projects list view with card or table layout
|
||||
- Project creation dialog/form
|
||||
- Project detail view (name, description, status, created/updated timestamps)
|
||||
- Wire to existing `/api/projects` (full CRUD already implemented)
|
||||
- Navigate from sidebar → /projects
|
||||
- **Status: COMPLETE (MS16) — PR #477. Card layout, create/delete dialogs, status badges.**
|
||||
|
||||
### FR-009: Terminal Panel (Future Milestone)
|
||||
### FR-009: Project Workspace Page (MS16)
|
||||
|
||||
- Single-project view showing tasks, agent sessions, and project settings
|
||||
- Task list for selected project
|
||||
- Agent session history and status
|
||||
- Wire to `/api/projects/:id`, `/api/tasks`, `/api/runner-jobs`
|
||||
- Navigate from sidebar → /workspace (with project context)
|
||||
- **Status: COMPLETE (MS16) — PR #479. Tabbed view (Tasks, Agent Sessions, Settings), project selector mode.**
|
||||
|
||||
### FR-010: Kanban Board Page (MS16)
|
||||
|
||||
- Drag-and-drop board with columns mapped to task status values
|
||||
- Task cards showing title, assignee, priority, status
|
||||
- Column headers with task counts
|
||||
- Wire to existing `/api/tasks` (status field drives columns)
|
||||
- Navigate from sidebar → /kanban
|
||||
- **Status: COMPLETE (MS16) — PR #478. 5 columns (NOT_STARTED→ARCHIVED), @hello-pangea/dnd, optimistic updates.**
|
||||
|
||||
### FR-011: File Manager Page (MS16)
|
||||
|
||||
- Tree or list view of knowledge entries
|
||||
- CRUD operations (create, read, update, delete)
|
||||
- Search functionality
|
||||
- Wire to existing `/api/knowledge` (full CRUD + search already implemented)
|
||||
- Navigate from sidebar → /files
|
||||
- **Status: COMPLETE (MS16) — PR #481. List+grid views, search, create/delete dialogs.**
|
||||
|
||||
### FR-012: Logs & Telemetry Page (MS16)
|
||||
|
||||
- Log viewer with timestamp, level, source, message columns
|
||||
- Filtering by level, source, date range
|
||||
- Auto-refresh for live logs
|
||||
- Wire to existing runner-jobs, job steps, and events APIs
|
||||
- Navigate from sidebar → /logs
|
||||
- **Status: COMPLETE (MS16) — PR #480. Auto-refresh (5s polling), expandable rows, filters.**
|
||||
|
||||
### FR-013: Settings Root Page (MS16)
|
||||
|
||||
- Landing/index page for settings
|
||||
- Category cards linking to existing subpages: Credentials, Domains, Personalities, Workspaces
|
||||
- Navigate from sidebar → /settings (currently 404; subpages exist)
|
||||
- **Status: COMPLETE (MS16) — PR #471. 4 category cards with icons and hover states.**
|
||||
|
||||
### FR-014: Custom 404 Page (MS16)
|
||||
|
||||
- Branded 404 page matching design system
|
||||
- Helpful message and navigation link back to dashboard
|
||||
- Applied to all unmatched routes within authenticated layout
|
||||
- **Status: COMPLETE (MS16) — PR #472. Global + authenticated route-group 404 pages.**
|
||||
|
||||
### FR-015: Mock Data Elimination (MS16+MS17)
|
||||
|
||||
- `/tasks` page: replace mock data with `/api/tasks` calls
|
||||
- `/calendar` page: replace mock data with `/api/events` calls
|
||||
- `/knowledge` pages: replace mock data with `/api/knowledge` calls
|
||||
- All pages must render real data from backend APIs
|
||||
- **Status: COMPLETE (MS16+MS17) — PRs #473-#476. 238+ lines of mock data removed.**
|
||||
|
||||
### FR-016: Theme System (MS18) — COMPLETE
|
||||
|
||||
- 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TypeScript theme packages
|
||||
- ThemeProvider loads themes dynamically, applies CSS variables, instant switching
|
||||
- Theme selection UI in Settings with live preview swatches
|
||||
- UserPreference.theme persists selection across sessions
|
||||
- **Status: COMPLETE (MS18) — PRs #493-495**
|
||||
|
||||
### FR-017: Terminal Panel (MS19)
|
||||
|
||||
- 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
|
||||
- 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-010: Settings Page (Future Milestone)
|
||||
### 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
|
||||
- Minimal launch env vars, rest configurable dynamically
|
||||
@@ -165,30 +331,83 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Milestone 0.0.15
|
||||
### MS15-DashboardShell — COMPLETE
|
||||
|
||||
1. Design tokens from dashboard.html are implemented in globals.css
|
||||
2. App shell shows full-width header with logo, collapsible sidebar, main content area
|
||||
3. Sidebar has all nav groups with icons, collapses to icon-only mode
|
||||
4. Hamburger button appears at mobile breakpoints, sidebar hidden by default
|
||||
5. Light/dark theme toggle works across all components
|
||||
6. Mosaic logo spinner is used as site-wide loading indicator
|
||||
7. Dashboard page shows metrics strip, orchestrator sessions, quick actions, activity feed, token budget
|
||||
8. All shared components in packages/ui use design tokens (no hardcoded colors)
|
||||
9. Lint, typecheck, and existing tests pass
|
||||
10. Grain overlay texture from reference is applied
|
||||
1. ~~Design tokens from dashboard.html are implemented in globals.css~~ DONE
|
||||
2. ~~App shell shows full-width header with logo, collapsible sidebar, main content area~~ DONE
|
||||
3. ~~Sidebar has all nav groups with icons, collapses to icon-only mode~~ DONE
|
||||
4. ~~Hamburger button appears at mobile breakpoints, sidebar hidden by default~~ DONE
|
||||
5. ~~Light/dark theme toggle works across all components~~ DONE
|
||||
6. ~~Mosaic logo spinner is used as site-wide loading indicator~~ DONE
|
||||
7. ~~Dashboard page shows metrics strip, orchestrator sessions, quick actions, activity feed, token budget~~ DONE
|
||||
8. ~~All shared components in packages/ui use design tokens (no hardcoded colors)~~ DONE
|
||||
9. ~~Lint, typecheck, and existing tests pass~~ DONE
|
||||
10. ~~Grain overlay texture from reference is applied~~ DONE
|
||||
|
||||
### Go-Live MVP (v0.1.0) — COMPLETE
|
||||
|
||||
11. ~~Dashboard widgets wired to real API data~~ DONE
|
||||
12. ~~WebSocket emits for agent job lifecycle~~ DONE
|
||||
13. ~~Deployed to mosaic.woltje.com with auth working~~ DONE
|
||||
|
||||
### MS16+MS17 — Pages & Data Integration — COMPLETE
|
||||
|
||||
14. ~~All sidebar links navigate to functional pages (no 404s)~~ DONE
|
||||
15. ~~Projects page: list, create, view project details~~ DONE
|
||||
16. ~~Workspace page: view single project with tasks and agent sessions~~ DONE
|
||||
17. ~~Kanban page: drag-and-drop board with task status columns~~ DONE
|
||||
18. ~~File Manager page: tree/list view with CRUD operations~~ DONE
|
||||
19. ~~Logs page: log viewer with filtering and auto-refresh~~ DONE
|
||||
20. ~~Settings root page: category index linking to subpages~~ DONE
|
||||
21. ~~Custom 404 page for unknown routes~~ DONE
|
||||
22. ~~`/tasks` page uses real API data (no mock)~~ DONE
|
||||
23. ~~`/calendar` page uses real API data (no mock)~~ DONE
|
||||
24. ~~`/knowledge` pages use real API data (no mock)~~ DONE
|
||||
25. ~~All new pages support light/dark theme~~ DONE
|
||||
26. ~~All new pages are responsive (sm/md/lg/xl breakpoints)~~ DONE
|
||||
27. ~~Lint, typecheck, and tests pass~~ 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)
|
||||
|
||||
11. jarvis user logs in via Authentik, has admin access to all pages
|
||||
12. jarvis-user has standard access at lower permission level
|
||||
13. Break-glass user has access without Authentik
|
||||
14. Three Mosaic Stack instances on Coolify with federation testing
|
||||
15. Playwright tests confirm all pages, functions, theming work
|
||||
16. No errors during site navigation
|
||||
17. API documented via Swagger with proper auth gating
|
||||
18. Telemetry working locally with wide-event logging
|
||||
19. Mosaic Telemetry properly reporting to telemetry endpoint
|
||||
52. jarvis user logs in via Authentik, has admin access to all pages
|
||||
53. jarvis-user has standard access at lower permission level
|
||||
54. Break-glass user has access without Authentik
|
||||
55. Three Mosaic Stack instances on Portainer with federation testing
|
||||
56. Playwright tests confirm all pages, functions, theming work
|
||||
57. No errors during site navigation
|
||||
58. API documented via Swagger with proper auth gating
|
||||
59. Telemetry working locally with wide-event logging
|
||||
60. Mosaic Telemetry properly reporting to telemetry endpoint
|
||||
|
||||
## Constraints and Dependencies
|
||||
|
||||
@@ -197,37 +416,79 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
||||
3. BetterAuth for authentication — must maintain existing auth flow
|
||||
4. Authentik as IdP at auth.diversecanvas.com — must remain operational
|
||||
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
|
||||
8. Backend API modules already exist for all page data needs — no new API endpoints required for MS16+MS17 scope
|
||||
|
||||
## Risks and Open Questions
|
||||
|
||||
1. **Risk**: Changing globals.css design tokens may break existing pages (login, knowledge, calendar). Mitigation: Thorough regression testing.
|
||||
2. **Risk**: packages/ui uses hardcoded Tailwind colors — migration to CSS variables needs care. Mitigation: Phase the migration, test each component.
|
||||
3. **Open**: Exact federation protocol details for master-master vs master-slave data sync.
|
||||
4. **Open**: Specific telemetry data points to collect.
|
||||
5. **Open**: Agent task mapping configuration schema (informed by OpenClaw research).
|
||||
1. **Risk**: Pages need to match the design system established in MS15. Inconsistency would degrade UX. Mitigation: Use existing design tokens and shared components exclusively. **RESOLVED** — All MS16+MS17 pages use design tokens consistently.
|
||||
2. **Risk**: Kanban drag-and-drop adds complexity and potential for state bugs. Mitigation: Use a proven DnD library. **RESOLVED** — @hello-pangea/dnd selected (maintained fork of react-beautiful-dnd, better TS support). Optimistic updates with rollback on failure.
|
||||
3. **Risk**: Mock data elimination may reveal backend API gaps or mismatches. Mitigation: Audit each API response shape against page needs during implementation. **RESOLVED** — All 3 mock-data pages wired successfully. No API gaps found.
|
||||
4. ~~**Open**: Exact task status values for Kanban columns~~ **RESOLVED** — TaskStatus enum: NOT_STARTED, IN_PROGRESS, PAUSED, COMPLETED, ARCHIVED (5 columns).
|
||||
5. ~~**Open**: Whether Workspace page should require project selection or show a default view~~ **RESOLVED** — Shows project selector when no project param, workspace detail when ?project=id.
|
||||
6. ~~**Open**: File Manager page — should it be a direct mapping of Knowledge entries or a separate file abstraction?~~ **RESOLVED** — Direct mapping to Knowledge entries via /api/knowledge. API shape matches file manager needs.
|
||||
|
||||
## Existing Backend API Modules (Reference)
|
||||
|
||||
These 19 NestJS modules are already implemented with Prisma and available for frontend wiring:
|
||||
|
||||
| Module | Endpoint | Capabilities |
|
||||
| ------------------ | ------------------------------ | --------------------- |
|
||||
| Projects | `/api/projects` | Full CRUD |
|
||||
| Tasks | `/api/tasks` | Full CRUD |
|
||||
| Layouts | `/api/layouts` | Widget placement |
|
||||
| Widgets | `/api/widgets` | Data endpoints |
|
||||
| Activity | `/api/activity` | Audit logs |
|
||||
| Dashboard | `/api/dashboard/summary` | Aggregated summary |
|
||||
| Knowledge | `/api/knowledge` | Full CRUD + search |
|
||||
| Ideas | `/api/ideas` | Capture/CRUD |
|
||||
| Domains | `/api/domains` | CRUD |
|
||||
| Events | `/api/events` | CRUD |
|
||||
| Preferences | `/api/users/me/preferences` | User settings |
|
||||
| Workspace Settings | `/api/workspaces/:id/settings` | LLM config |
|
||||
| Runner Jobs | `/api/runner-jobs` | Job management |
|
||||
| Job Steps | `/api/runner-jobs/:id/steps` | Step tracking |
|
||||
| Agent Tasks | `/api/agent-tasks` | Agent task management |
|
||||
| Credentials | `/api/credentials` | Encrypted storage |
|
||||
| Brain/AI | `/api/brain` | Query/search |
|
||||
| WebSocket | Real-time | Event broadcasting |
|
||||
| LLM | `/api/llm/chat` | Chat + SSE streaming |
|
||||
| Orchestrator Proxy | `/api/orchestrator/*` | Agent mgmt proxy |
|
||||
| Telemetry | Internal | Logging/monitoring |
|
||||
|
||||
## Testing and Verification
|
||||
|
||||
1. Baseline: `pnpm lint && pnpm build` must pass
|
||||
2. Situational: Visual verification at sm/md/lg/xl breakpoints
|
||||
3. Situational: Theme toggle across all pages
|
||||
4. Situational: Sidebar collapse/expand at all breakpoints
|
||||
5. E2E: Playwright tests for all page navigation
|
||||
6. E2E: Auth flow with Authentik
|
||||
7. Federation: Master-master and master-slave data access tests
|
||||
2. Situational: All sidebar links navigate without 404
|
||||
3. Situational: Each new page renders with real API data
|
||||
4. Situational: Theme toggle on each new page
|
||||
5. Situational: Responsive verification at sm/md/lg/xl
|
||||
6. E2E: Playwright tests for all page navigation (MS23)
|
||||
7. E2E: Auth flow with Authentik (MS23)
|
||||
8. Federation: Master-master and master-slave data access tests (MS21)
|
||||
|
||||
## Delivery/Milestone Intent
|
||||
|
||||
| Milestone | Version | Focus |
|
||||
| ----------------------- | ------- | ----------------------------------------------------------------- |
|
||||
| MS15-DashboardShell | 0.0.15 | Design system + app shell + dashboard page |
|
||||
| MS16-Pages | 0.0.16 | Projects, Workspace, Kanban, Settings, Profile, Files, Logs pages |
|
||||
| MS17-BackendIntegration | 0.0.17 | API endpoints, real data, Swagger docs |
|
||||
| MS18-ThemeWidgets | 0.0.18 | Theme package system, widget registry, dashboard customization |
|
||||
| MS19-ChatTerminal | 0.0.19 | Global terminal, project chat, master chat session |
|
||||
| MS20-MultiTenant | 0.0.20 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth |
|
||||
| MS21-Federation | 0.0.21 | Federation (M-M, M-S), 3 instances, key exchange, data separation |
|
||||
| MS22-AgentTelemetry | 0.0.22 | Agent task mapping, telemetry, wide-event logging |
|
||||
| MS23-Testing | 0.0.23 | Playwright E2E, federation tests, documentation finalization |
|
||||
| Milestone | Version | Focus | Status |
|
||||
| ------------------------------ | ------- | ----------------------------------------------------------------- | ----------- |
|
||||
| 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 |
|
||||
| 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, WYSIWYG, Kanban filtering | COMPLETE |
|
||||
| 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 |
|
||||
| MS21-Federation | 0.2.x | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
|
||||
| MS22-AgentTelemetry | 0.2.x | Agent task mapping, telemetry, wide-event logging | NOT STARTED |
|
||||
| MS23-Testing | 0.2.x | Playwright E2E, federation tests, documentation finalization | NOT STARTED |
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. ASSUMPTION: Header spans full width including above sidebar area. The logo is in the header, not the sidebar. Rationale: User explicitly stated "The logo will NOT be part of the sidebar."
|
||||
2. ASSUMPTION: Sidebar footer user card navigates to Profile page. Rationale: Matches reference design behavior.
|
||||
3. ASSUMPTION: Initial implementation supports dark/light from reference design. Multi-theme package system is a future milestone. Rationale: Foundation must be solid before extensibility.
|
||||
4. ASSUMPTION: MS16 and MS17 are combined into a single mission because 19 backend API modules already exist with real Prisma business logic. The remaining work is primarily frontend page creation and API wiring. Rationale: Backend audit on 2026-02-22 confirmed all required endpoints are implemented.
|
||||
5. ASSUMPTION: File Manager page maps to Knowledge entries rather than a separate file system abstraction. Rationale: `/api/knowledge` provides full CRUD + search which matches file manager needs. Can be extended later if needed.
|
||||
6. ASSUMPTION: Theme packages are code-level TypeScript files (not runtime-installable npm packages). Each theme exports CSS variable overrides. Rationale: Keeps the system simple for MS18; runtime package loading can be added in a future milestone.
|
||||
7. ASSUMPTION: WYSIWYG editor uses Tiptap (ProseMirror-based, headless). Rationale: Headless approach integrates naturally with the CSS variable design system, excellent markdown import/export, TypeScript-first, battle-tested.
|
||||
8. ASSUMPTION: MS18 includes WYSIWYG editing for knowledge entries and Kanban filtering enhancements in addition to themes and widgets. These were originally listed separately but are grouped into MS18 per PRD scope items 24-25. Rationale: All are frontend-focused enhancements that build on the existing page infrastructure.
|
||||
|
||||
@@ -1,13 +1,53 @@
|
||||
# Tasks — Mosaic Stack Go-Live MVP
|
||||
# Tasks — MS19 Chat & Terminal System
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
|
||||
| id | status | milestone | description | pr | notes |
|
||||
| --------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---- | ---------------------------------------------- |
|
||||
| MS-P1-001 | done | phase-1 | Fix broken test suites: Button.test.tsx (4 fails, old Tailwind classes) + page.test.tsx (5 fails, old widget refs) | #458 | issue #457, commit 8fa0b30 |
|
||||
| MS-P1-002 | done | phase-1 | Remove legacy unused dashboard widgets: DomainOverviewWidget, RecentTasksWidget, UpcomingEventsWidget, QuickCaptureWidget | #458 | issue #457, commit 8fa0b30, 5 files deleted |
|
||||
| MS-P1-003 | done | phase-1 | Visual + theme polish: audit current vs design reference, fix gaps, verify dark/light across all components, responsive verification | #458 | issue #457, commit d97a98b, review: approve |
|
||||
| MS-P1-004 | done | phase-1 | Phase verification: all quality gates pass (lint 8/8, typecheck 7/7, test 8/8) | #458 | issue #457, merged 07f5225, issue closed |
|
||||
| MS-P2-001 | done | phase-2 | Create dashboard summary API endpoint: aggregate task counts, project counts, recent activity, active jobs in single call | — | issue #459, commit e38aaa9, 7 files +430 lines |
|
||||
| MS-P2-002 | done | phase-2 | Wire dashboard widgets to real API data: ActivityFeed, DashboardMetrics, OrchestratorSessions replace mock with API calls | — | issue #459, commit 7c762e6 + remediation |
|
||||
| MS-P2-003 | done | phase-2 | Phase verification: create task via API, confirm visible in dashboard, all quality gates pass | — | issue #459, lint 8/8 typecheck 7/7 test 8/8 |
|
||||
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||
| ----------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------- | ------------------------------ | ----------------------------------------------- | ----------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ---------------------------------------------------------------- |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 | | |
|
||||
| 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 |
|
||||
| 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 | | |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 | | |
|
||||
| CT-DOC-001 | not-started | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #512 | — | — | CT-VER-001 | CT-VER-002 | orchestrator | | | 10K | | |
|
||||
| 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 | | |
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
| --------------- | ----------------- |
|
||||
| Total tasks | 12 |
|
||||
| Completed | 1 (planning) |
|
||||
| In Progress | 0 |
|
||||
| Remaining | 11 |
|
||||
| Estimated total | ~250K tokens |
|
||||
| Milestone | MS19-ChatTerminal |
|
||||
|
||||
## 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)
|
||||
|
||||
39
docs/scratchpads/446-auth-divider-padding.md
Normal file
39
docs/scratchpads/446-auth-divider-padding.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Scratchpad: #446 — AuthDivider Padding
|
||||
|
||||
## Objective
|
||||
|
||||
Add padding above and below the "OR CONTINUE WITH" divider on the login page.
|
||||
|
||||
## Component Location
|
||||
|
||||
`packages/ui/src/components/AuthSurface.tsx` — `AuthDivider` component
|
||||
|
||||
## Current State
|
||||
|
||||
- `AuthDivider` has `my-8` (32px margin top/bottom) on the outer div
|
||||
- Used in `apps/web/src/app/(auth)/login/page.tsx` line 287
|
||||
- Parent container uses `space-y-0`
|
||||
|
||||
## Change
|
||||
|
||||
Increase `my-8` to `my-10` (40px) to add more visual breathing room around the divider.
|
||||
|
||||
ASSUMPTION: `my-10` (40px) is sufficient extra breathing room. If visual review shows otherwise, can adjust.
|
||||
|
||||
## Steps
|
||||
|
||||
- [x] Issue #446 created
|
||||
- [x] Scratchpad created
|
||||
- [x] Make change (py-8 instead of my-8)
|
||||
- [x] Code review (passed — independent agent review confirmed fix is correct)
|
||||
- [x] Commit/push (d7a8ebc → PR #447)
|
||||
- [x] CI green (all 3 pipelines: web=success, api=success, orchestrator=success)
|
||||
- [x] PR merged (9b5c15c on main)
|
||||
- [x] Coolify redeploy (pre-pulled images, service running:healthy)
|
||||
- [x] Playwright verify (paddingTop=32px, paddingBottom=32px confirmed)
|
||||
- [x] Issue closed (#446)
|
||||
|
||||
## Result
|
||||
|
||||
COMPLETE. Root cause: `space-y-0` parent overrides `margin-top` on AuthDivider via CSS sibling
|
||||
selector. Fix: changed `my-8` to `py-8` so spacing is internal padding, not external margin.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user