Compare commits
37 Commits
v0.0.15
...
7cc5538721
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cc5538721 | |||
| 48cbd0e29e | |||
| 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 | |||
| 7581d26567 | |||
| 07f5225a76 | |||
| 7c55464d54 | |||
| ea1620fa7a |
14
.mosaic/orchestrator/mission.json
Normal file
14
.mosaic/orchestrator/mission.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"mission_id": "prd-implementation-20260222",
|
||||||
|
"name": "PRD implementation",
|
||||||
|
"description": "",
|
||||||
|
"project_path": "/home/jwoltje/src/mosaic-stack",
|
||||||
|
"created_at": "2026-02-23T03:20:55Z",
|
||||||
|
"status": "active",
|
||||||
|
"task_prefix": "",
|
||||||
|
"quality_gates": "",
|
||||||
|
"milestone_version": "0.0.1",
|
||||||
|
"milestones": [],
|
||||||
|
"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
|
// Use transaction for atomic seed data reset and creation
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
// Delete existing seed data for idempotency (avoids duplicates on re-run)
|
// Delete existing seed data for idempotency (avoids duplicates on re-run)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { FederationModule } from "./federation/federation.module";
|
|||||||
import { CredentialsModule } from "./credentials/credentials.module";
|
import { CredentialsModule } from "./credentials/credentials.module";
|
||||||
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
||||||
import { SpeechModule } from "./speech/speech.module";
|
import { SpeechModule } from "./speech/speech.module";
|
||||||
|
import { DashboardModule } from "./dashboard/dashboard.module";
|
||||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -101,6 +102,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
CredentialsModule,
|
CredentialsModule,
|
||||||
MosaicTelemetryModule,
|
MosaicTelemetryModule,
|
||||||
SpeechModule,
|
SpeechModule,
|
||||||
|
DashboardModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
143
apps/api/src/dashboard/dashboard.controller.spec.ts
Normal file
143
apps/api/src/dashboard/dashboard.controller.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { DashboardController } from "./dashboard.controller";
|
||||||
|
import { DashboardService } from "./dashboard.service";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||||
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
|
import type { DashboardSummaryDto } from "./dto";
|
||||||
|
|
||||||
|
describe("DashboardController", () => {
|
||||||
|
let controller: DashboardController;
|
||||||
|
let service: DashboardService;
|
||||||
|
|
||||||
|
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
||||||
|
|
||||||
|
const mockSummary: DashboardSummaryDto = {
|
||||||
|
metrics: {
|
||||||
|
activeAgents: 3,
|
||||||
|
tasksCompleted: 12,
|
||||||
|
totalTasks: 25,
|
||||||
|
tasksInProgress: 5,
|
||||||
|
activeProjects: 4,
|
||||||
|
errorRate: 2.5,
|
||||||
|
},
|
||||||
|
recentActivity: [
|
||||||
|
{
|
||||||
|
id: "550e8400-e29b-41d4-a716-446655440010",
|
||||||
|
action: "CREATED",
|
||||||
|
entityType: "TASK",
|
||||||
|
entityId: "550e8400-e29b-41d4-a716-446655440011",
|
||||||
|
details: { title: "New task" },
|
||||||
|
userId: "550e8400-e29b-41d4-a716-446655440002",
|
||||||
|
createdAt: "2026-02-22T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeJobs: [
|
||||||
|
{
|
||||||
|
id: "550e8400-e29b-41d4-a716-446655440020",
|
||||||
|
type: "code-task",
|
||||||
|
status: "RUNNING",
|
||||||
|
progressPercent: 45,
|
||||||
|
createdAt: "2026-02-22T11:00:00.000Z",
|
||||||
|
updatedAt: "2026-02-22T11:30:00.000Z",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: "550e8400-e29b-41d4-a716-446655440030",
|
||||||
|
name: "Setup",
|
||||||
|
status: "COMPLETED",
|
||||||
|
phase: "SETUP",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokenBudget: [
|
||||||
|
{
|
||||||
|
model: "agent-1",
|
||||||
|
used: 5000,
|
||||||
|
limit: 10000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockDashboardService = {
|
||||||
|
getSummary: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAuthGuard = {
|
||||||
|
canActivate: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkspaceGuard = {
|
||||||
|
canActivate: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPermissionGuard = {
|
||||||
|
canActivate: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [DashboardController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: DashboardService,
|
||||||
|
useValue: mockDashboardService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue(mockAuthGuard)
|
||||||
|
.overrideGuard(WorkspaceGuard)
|
||||||
|
.useValue(mockWorkspaceGuard)
|
||||||
|
.overrideGuard(PermissionGuard)
|
||||||
|
.useValue(mockPermissionGuard)
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<DashboardController>(DashboardController);
|
||||||
|
service = module.get<DashboardService>(DashboardService);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSummary", () => {
|
||||||
|
it("should return dashboard summary for workspace", async () => {
|
||||||
|
mockDashboardService.getSummary.mockResolvedValue(mockSummary);
|
||||||
|
|
||||||
|
const result = await controller.getSummary(mockWorkspaceId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSummary);
|
||||||
|
expect(service.getSummary).toHaveBeenCalledWith(mockWorkspaceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty arrays when no data exists", async () => {
|
||||||
|
const emptySummary: DashboardSummaryDto = {
|
||||||
|
metrics: {
|
||||||
|
activeAgents: 0,
|
||||||
|
tasksCompleted: 0,
|
||||||
|
totalTasks: 0,
|
||||||
|
tasksInProgress: 0,
|
||||||
|
activeProjects: 0,
|
||||||
|
errorRate: 0,
|
||||||
|
},
|
||||||
|
recentActivity: [],
|
||||||
|
activeJobs: [],
|
||||||
|
tokenBudget: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDashboardService.getSummary.mockResolvedValue(emptySummary);
|
||||||
|
|
||||||
|
const result = await controller.getSummary(mockWorkspaceId);
|
||||||
|
|
||||||
|
expect(result).toEqual(emptySummary);
|
||||||
|
expect(result.metrics.errorRate).toBe(0);
|
||||||
|
expect(result.recentActivity).toHaveLength(0);
|
||||||
|
expect(result.activeJobs).toHaveLength(0);
|
||||||
|
expect(result.tokenBudget).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
35
apps/api/src/dashboard/dashboard.controller.ts
Normal file
35
apps/api/src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Controller, Get, UseGuards, BadRequestException } from "@nestjs/common";
|
||||||
|
import { DashboardService } from "./dashboard.service";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
|
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||||
|
import type { DashboardSummaryDto } from "./dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for dashboard endpoints.
|
||||||
|
* Returns aggregated summary data for the workspace dashboard.
|
||||||
|
*
|
||||||
|
* Guards are applied in order:
|
||||||
|
* 1. AuthGuard - Verifies user authentication
|
||||||
|
* 2. WorkspaceGuard - Validates workspace access and sets RLS context
|
||||||
|
* 3. PermissionGuard - Checks role-based permissions
|
||||||
|
*/
|
||||||
|
@Controller("dashboard")
|
||||||
|
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||||
|
export class DashboardController {
|
||||||
|
constructor(private readonly dashboardService: DashboardService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/dashboard/summary
|
||||||
|
* Returns aggregated metrics, recent activity, active jobs, and token budgets
|
||||||
|
* Requires: Any workspace member (including GUEST)
|
||||||
|
*/
|
||||||
|
@Get("summary")
|
||||||
|
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||||
|
async getSummary(@Workspace() workspaceId: string | undefined): Promise<DashboardSummaryDto> {
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new BadRequestException("Workspace context required");
|
||||||
|
}
|
||||||
|
return this.dashboardService.getSummary(workspaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/dashboard/dashboard.module.ts
Normal file
13
apps/api/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { DashboardController } from "./dashboard.controller";
|
||||||
|
import { DashboardService } from "./dashboard.service";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, AuthModule],
|
||||||
|
controllers: [DashboardController],
|
||||||
|
providers: [DashboardService],
|
||||||
|
exports: [DashboardService],
|
||||||
|
})
|
||||||
|
export class DashboardModule {}
|
||||||
187
apps/api/src/dashboard/dashboard.service.ts
Normal file
187
apps/api/src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { AgentStatus, ProjectStatus, RunnerJobStatus, TaskStatus } from "@prisma/client";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type {
|
||||||
|
DashboardSummaryDto,
|
||||||
|
ActiveJobDto,
|
||||||
|
RecentActivityDto,
|
||||||
|
TokenBudgetEntryDto,
|
||||||
|
} from "./dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for aggregating dashboard summary data.
|
||||||
|
* Executes all queries in parallel to minimize latency.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get aggregated dashboard summary for a workspace
|
||||||
|
*/
|
||||||
|
async getSummary(workspaceId: string): Promise<DashboardSummaryDto> {
|
||||||
|
const now = new Date();
|
||||||
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Execute all queries in parallel
|
||||||
|
const [
|
||||||
|
activeAgents,
|
||||||
|
tasksCompleted,
|
||||||
|
totalTasks,
|
||||||
|
tasksInProgress,
|
||||||
|
activeProjects,
|
||||||
|
failedJobsLast24h,
|
||||||
|
totalJobsLast24h,
|
||||||
|
recentActivityRows,
|
||||||
|
activeJobRows,
|
||||||
|
tokenBudgetRows,
|
||||||
|
] = await Promise.all([
|
||||||
|
// Active agents: IDLE, WORKING, WAITING
|
||||||
|
this.prisma.agent.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: { in: [AgentStatus.IDLE, AgentStatus.WORKING, AgentStatus.WAITING] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Tasks completed
|
||||||
|
this.prisma.task.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: TaskStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Total tasks
|
||||||
|
this.prisma.task.count({
|
||||||
|
where: { workspaceId },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Tasks in progress
|
||||||
|
this.prisma.task.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: TaskStatus.IN_PROGRESS,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Active projects
|
||||||
|
this.prisma.project.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: ProjectStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Failed jobs in last 24h (for error rate)
|
||||||
|
this.prisma.runnerJob.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: RunnerJobStatus.FAILED,
|
||||||
|
createdAt: { gte: oneDayAgo },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Total jobs in last 24h (for error rate)
|
||||||
|
this.prisma.runnerJob.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
createdAt: { gte: oneDayAgo },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Recent activity: last 10 entries
|
||||||
|
this.prisma.activityLog.findMany({
|
||||||
|
where: { workspaceId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Active jobs: PENDING, QUEUED, RUNNING with steps
|
||||||
|
this.prisma.runnerJob.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: {
|
||||||
|
in: [RunnerJobStatus.PENDING, RunnerJobStatus.QUEUED, RunnerJobStatus.RUNNING],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
steps: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
phase: true,
|
||||||
|
},
|
||||||
|
orderBy: { ordinal: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Token budgets for workspace (active, not yet completed)
|
||||||
|
this.prisma.tokenBudget.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
agentId: true,
|
||||||
|
totalTokensUsed: true,
|
||||||
|
allocatedTokens: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Compute error rate
|
||||||
|
const errorRate = totalJobsLast24h > 0 ? (failedJobsLast24h / totalJobsLast24h) * 100 : 0;
|
||||||
|
|
||||||
|
// Map recent activity
|
||||||
|
const recentActivity: RecentActivityDto[] = recentActivityRows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
action: row.action,
|
||||||
|
entityType: row.entityType,
|
||||||
|
entityId: row.entityId,
|
||||||
|
details: row.details as Record<string, unknown> | null,
|
||||||
|
userId: row.userId,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Map active jobs (RunnerJob lacks updatedAt; use startedAt or createdAt as proxy)
|
||||||
|
const activeJobs: ActiveJobDto[] = activeJobRows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
type: row.type,
|
||||||
|
status: row.status,
|
||||||
|
progressPercent: row.progressPercent,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: (row.startedAt ?? row.createdAt).toISOString(),
|
||||||
|
steps: row.steps.map((step) => ({
|
||||||
|
id: step.id,
|
||||||
|
name: step.name,
|
||||||
|
status: step.status,
|
||||||
|
phase: step.phase,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Map token budget entries
|
||||||
|
const tokenBudget: TokenBudgetEntryDto[] = tokenBudgetRows.map((row) => ({
|
||||||
|
model: row.agentId,
|
||||||
|
used: row.totalTokensUsed,
|
||||||
|
limit: row.allocatedTokens,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
metrics: {
|
||||||
|
activeAgents,
|
||||||
|
tasksCompleted,
|
||||||
|
totalTasks,
|
||||||
|
tasksInProgress,
|
||||||
|
activeProjects,
|
||||||
|
errorRate: Math.round(errorRate * 100) / 100,
|
||||||
|
},
|
||||||
|
recentActivity,
|
||||||
|
activeJobs,
|
||||||
|
tokenBudget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/api/src/dashboard/dto/dashboard-summary.dto.ts
Normal file
53
apps/api/src/dashboard/dto/dashboard-summary.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard Summary DTO
|
||||||
|
* Defines the response shape for the dashboard summary endpoint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class DashboardMetricsDto {
|
||||||
|
activeAgents!: number;
|
||||||
|
tasksCompleted!: number;
|
||||||
|
totalTasks!: number;
|
||||||
|
tasksInProgress!: number;
|
||||||
|
activeProjects!: number;
|
||||||
|
errorRate!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecentActivityDto {
|
||||||
|
id!: string;
|
||||||
|
action!: string;
|
||||||
|
entityType!: string;
|
||||||
|
entityId!: string;
|
||||||
|
details!: Record<string, unknown> | null;
|
||||||
|
userId!: string;
|
||||||
|
createdAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActiveJobStepDto {
|
||||||
|
id!: string;
|
||||||
|
name!: string;
|
||||||
|
status!: string;
|
||||||
|
phase!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActiveJobDto {
|
||||||
|
id!: string;
|
||||||
|
type!: string;
|
||||||
|
status!: string;
|
||||||
|
progressPercent!: number;
|
||||||
|
createdAt!: string;
|
||||||
|
updatedAt!: string;
|
||||||
|
steps!: ActiveJobStepDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TokenBudgetEntryDto {
|
||||||
|
model!: string;
|
||||||
|
used!: number;
|
||||||
|
limit!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DashboardSummaryDto {
|
||||||
|
metrics!: DashboardMetricsDto;
|
||||||
|
recentActivity!: RecentActivityDto[];
|
||||||
|
activeJobs!: ActiveJobDto[];
|
||||||
|
tokenBudget!: TokenBudgetEntryDto[];
|
||||||
|
}
|
||||||
1
apps/api/src/dashboard/dto/index.ts
Normal file
1
apps/api/src/dashboard/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./dashboard-summary.dto";
|
||||||
@@ -4,6 +4,7 @@ import { RunnerJobsService } from "./runner-jobs.service";
|
|||||||
import { PrismaModule } from "../prisma/prisma.module";
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
import { BullMqModule } from "../bullmq/bullmq.module";
|
import { BullMqModule } from "../bullmq/bullmq.module";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { WebSocketModule } from "../websocket/websocket.module";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runner Jobs Module
|
* Runner Jobs Module
|
||||||
@@ -12,7 +13,7 @@ import { AuthModule } from "../auth/auth.module";
|
|||||||
* for asynchronous job processing.
|
* for asynchronous job processing.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, BullMqModule, AuthModule],
|
imports: [PrismaModule, BullMqModule, AuthModule, WebSocketModule],
|
||||||
controllers: [RunnerJobsController],
|
controllers: [RunnerJobsController],
|
||||||
providers: [RunnerJobsService],
|
providers: [RunnerJobsService],
|
||||||
exports: [RunnerJobsService],
|
exports: [RunnerJobsService],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { RunnerJobsService } from "./runner-jobs.service";
|
import { RunnerJobsService } from "./runner-jobs.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { BullMqService } from "../bullmq/bullmq.service";
|
import { BullMqService } from "../bullmq/bullmq.service";
|
||||||
|
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||||
import { RunnerJobStatus } from "@prisma/client";
|
import { RunnerJobStatus } from "@prisma/client";
|
||||||
import { ConflictException, BadRequestException } from "@nestjs/common";
|
import { ConflictException, BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
@@ -19,6 +20,12 @@ describe("RunnerJobsService - Concurrency", () => {
|
|||||||
getQueue: vi.fn(),
|
getQueue: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockWebSocketGateway = {
|
||||||
|
emitJobCreated: vi.fn(),
|
||||||
|
emitJobStatusChanged: vi.fn(),
|
||||||
|
emitJobProgress: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -37,6 +44,10 @@ describe("RunnerJobsService - Concurrency", () => {
|
|||||||
provide: BullMqService,
|
provide: BullMqService,
|
||||||
useValue: mockBullMqService,
|
useValue: mockBullMqService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: WebSocketGateway,
|
||||||
|
useValue: mockWebSocketGateway,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { RunnerJobsService } from "./runner-jobs.service";
|
import { RunnerJobsService } from "./runner-jobs.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { BullMqService } from "../bullmq/bullmq.service";
|
import { BullMqService } from "../bullmq/bullmq.service";
|
||||||
|
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||||
import { RunnerJobStatus } from "@prisma/client";
|
import { RunnerJobStatus } from "@prisma/client";
|
||||||
import { NotFoundException, BadRequestException } from "@nestjs/common";
|
import { NotFoundException, BadRequestException } from "@nestjs/common";
|
||||||
import { CreateJobDto, QueryJobsDto } from "./dto";
|
import { CreateJobDto, QueryJobsDto } from "./dto";
|
||||||
@@ -32,6 +33,12 @@ describe("RunnerJobsService", () => {
|
|||||||
getQueue: vi.fn(),
|
getQueue: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockWebSocketGateway = {
|
||||||
|
emitJobCreated: vi.fn(),
|
||||||
|
emitJobStatusChanged: vi.fn(),
|
||||||
|
emitJobProgress: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -44,6 +51,10 @@ describe("RunnerJobsService", () => {
|
|||||||
provide: BullMqService,
|
provide: BullMqService,
|
||||||
useValue: mockBullMqService,
|
useValue: mockBullMqService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: WebSocketGateway,
|
||||||
|
useValue: mockWebSocketGateway,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Prisma, RunnerJobStatus } from "@prisma/client";
|
|||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { BullMqService } from "../bullmq/bullmq.service";
|
import { BullMqService } from "../bullmq/bullmq.service";
|
||||||
|
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||||
import { QUEUE_NAMES } from "../bullmq/queues";
|
import { QUEUE_NAMES } from "../bullmq/queues";
|
||||||
import { ConcurrentUpdateException } from "../common/exceptions/concurrent-update.exception";
|
import { ConcurrentUpdateException } from "../common/exceptions/concurrent-update.exception";
|
||||||
import type { CreateJobDto, QueryJobsDto } from "./dto";
|
import type { CreateJobDto, QueryJobsDto } from "./dto";
|
||||||
@@ -14,7 +15,8 @@ import type { CreateJobDto, QueryJobsDto } from "./dto";
|
|||||||
export class RunnerJobsService {
|
export class RunnerJobsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly bullMq: BullMqService
|
private readonly bullMq: BullMqService,
|
||||||
|
private readonly wsGateway: WebSocketGateway
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +58,8 @@ export class RunnerJobsService {
|
|||||||
{ priority }
|
{ priority }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.wsGateway.emitJobCreated(workspaceId, job);
|
||||||
|
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +198,13 @@ export class RunnerJobsService {
|
|||||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after cancel`);
|
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;
|
return job;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -248,6 +259,8 @@ export class RunnerJobsService {
|
|||||||
{ priority: existingJob.priority }
|
{ priority: existingJob.priority }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.wsGateway.emitJobCreated(workspaceId, newJob);
|
||||||
|
|
||||||
return newJob;
|
return newJob;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,6 +543,13 @@ export class RunnerJobsService {
|
|||||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
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;
|
return updatedJob;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -606,6 +626,12 @@ export class RunnerJobsService {
|
|||||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.wsGateway.emitJobProgress(workspaceId, id, {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
progressPercent: updatedJob.progressPercent,
|
||||||
|
});
|
||||||
|
|
||||||
return updatedJob;
|
return updatedJob;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// 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/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^9.0.0",
|
"@dnd-kit/sortable": "^9.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@mosaic/shared": "workspace:*",
|
"@mosaic/shared": "workspace:*",
|
||||||
"@mosaic/ui": "workspace:*",
|
"@mosaic/ui": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.90.20",
|
"@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",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@xyflow/react": "^12.5.3",
|
"@xyflow/react": "^12.5.3",
|
||||||
"better-auth": "^1.4.17",
|
"better-auth": "^1.4.17",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"elkjs": "^0.9.3",
|
"elkjs": "^0.9.3",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mermaid": "^11.4.1",
|
"mermaid": "^11.4.1",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
@@ -34,7 +46,8 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-grid-layout": "^2.2.2",
|
"react-grid-layout": "^2.2.2",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"socket.io-client": "^4.8.3"
|
"socket.io-client": "^4.8.3",
|
||||||
|
"tiptap-markdown": "^0.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mosaic/config": "workspace:*",
|
"@mosaic/config": "workspace:*",
|
||||||
|
|||||||
@@ -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 { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type { Event } from "@mosaic/shared";
|
||||||
import CalendarPage from "./page";
|
import CalendarPage from "./page";
|
||||||
|
|
||||||
// Mock the Calendar component
|
// 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 => {
|
describe("CalendarPage", (): void => {
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUseWorkspaceId.mockReturnValue("ws-1");
|
||||||
|
mockFetchEvents.mockResolvedValue(fakeEvents);
|
||||||
|
});
|
||||||
|
|
||||||
it("should render the page title", (): void => {
|
it("should render the page title", (): void => {
|
||||||
render(<CalendarPage />);
|
render(<CalendarPage />);
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Calendar");
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Calendar");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show loading state initially", (): void => {
|
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 />);
|
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> => {
|
it("should render the Calendar with events after loading", async (): Promise<void> => {
|
||||||
@@ -43,4 +123,31 @@ describe("CalendarPage", (): void => {
|
|||||||
render(<CalendarPage />);
|
render(<CalendarPage />);
|
||||||
expect(screen.getByText("View your schedule at a glance")).toBeInTheDocument();
|
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 { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Calendar } from "@/components/calendar/Calendar";
|
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";
|
import type { Event } from "@mosaic/shared";
|
||||||
|
|
||||||
export default function CalendarPage(): ReactElement {
|
export default function CalendarPage(): ReactElement {
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadEvents();
|
if (!workspaceId) {
|
||||||
}, []);
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsId = workspaceId;
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
async function loadEvents(): Promise<void> {
|
async function loadEvents(): Promise<void> {
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Replace with real API call when backend is ready
|
const data = await fetchEvents(wsId);
|
||||||
// const data = await fetchEvents();
|
if (!cancelled) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
setEvents(data);
|
||||||
setEvents(mockEvents);
|
}
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
|
console.error("[Calendar] Failed to fetch events:", err);
|
||||||
|
if (!cancelled) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: "We had trouble loading your calendar. Please try again when you're ready."
|
: "We had trouble loading your calendar. Please try again when you're ready."
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
setIsLoading(false);
|
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 (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Calendar</h1>
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
<p className="text-gray-600 mt-2">View your schedule at a glance</p>
|
Calendar
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "var(--text-muted)" }} className="mt-2">
|
||||||
|
View your schedule at a glance
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error !== null ? (
|
{events.length === 0 ? (
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
<div
|
||||||
<p className="text-amber-800">{error}</p>
|
className="rounded-lg p-8 text-center"
|
||||||
<button
|
style={{
|
||||||
onClick={() => void loadEvents()}
|
background: "var(--surface)",
|
||||||
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Try again
|
<p className="text-lg" style={{ color: "var(--text-muted)" }}>
|
||||||
</button>
|
No events scheduled
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-2" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Your calendar is clear
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Calendar events={events} isLoading={isLoading} />
|
<Calendar events={events} isLoading={false} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</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 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 type { EntryStatus } from "@mosaic/shared";
|
||||||
import { EntryList } from "@/components/knowledge/EntryList";
|
import { EntryList } from "@/components/knowledge/EntryList";
|
||||||
import { EntryFilters } from "@/components/knowledge/EntryFilters";
|
import { EntryFilters } from "@/components/knowledge/EntryFilters";
|
||||||
import { ImportExportActions } from "@/components/knowledge";
|
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 Link from "next/link";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
export default function KnowledgePage(): ReactElement {
|
export default function KnowledgePage(): ReactElement {
|
||||||
// TODO: Replace with real API call when backend is ready
|
// Data state
|
||||||
// const { data: entries, isLoading } = useQuery({
|
const [entries, setEntries] = useState<KnowledgeEntryWithTags[]>([]);
|
||||||
// queryKey: ["knowledge-entries"],
|
const [tags, setTags] = useState<KnowledgeTag[]>([]);
|
||||||
// queryFn: fetchEntries,
|
const [totalEntries, setTotalEntries] = useState(0);
|
||||||
// });
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading] = useState(false);
|
|
||||||
|
|
||||||
// Filter and sort state
|
// Filter and sort state
|
||||||
const [selectedStatus, setSelectedStatus] = useState<EntryStatus | "all">("all");
|
const [selectedStatus, setSelectedStatus] = useState<EntryStatus | "all">("all");
|
||||||
@@ -31,60 +33,65 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 10;
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
// Client-side filtering and sorting
|
// Load tags on mount
|
||||||
const filteredAndSortedEntries = useMemo(() => {
|
useEffect(() => {
|
||||||
let filtered = [...mockEntries];
|
let cancelled = false;
|
||||||
|
|
||||||
// Filter by status
|
fetchTags()
|
||||||
if (selectedStatus !== "all") {
|
.then((result) => {
|
||||||
filtered = filtered.filter((entry) => entry.status === selectedStatus);
|
if (!cancelled) {
|
||||||
|
setTags(result);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
// Filter by tag
|
.catch((err: unknown) => {
|
||||||
if (selectedTag !== "all") {
|
console.error("Failed to load tags:", err);
|
||||||
filtered = filtered.filter((entry) =>
|
|
||||||
entry.tags.some((tag: { slug: string }) => tag.slug === selectedTag)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort entries
|
|
||||||
filtered.sort((a, b) => {
|
|
||||||
let comparison = 0;
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortOrder === "asc" ? comparison : -comparison;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
return (): void => {
|
||||||
}, [selectedStatus, selectedTag, searchQuery, sortBy, sortOrder]);
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Pagination
|
// Load entries when filters/sort/page change
|
||||||
const totalPages = Math.ceil(filteredAndSortedEntries.length / itemsPerPage);
|
const loadEntries = useCallback(async (): Promise<void> => {
|
||||||
const paginatedEntries = filteredAndSortedEntries.slice(
|
setIsLoading(true);
|
||||||
(currentPage - 1) * itemsPerPage,
|
setError(null);
|
||||||
currentPage * itemsPerPage
|
|
||||||
|
try {
|
||||||
|
const filters: Record<string, unknown> = {
|
||||||
|
page: currentPage,
|
||||||
|
limit: itemsPerPage,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedStatus !== "all") {
|
||||||
|
filters.status = selectedStatus;
|
||||||
|
}
|
||||||
|
if (selectedTag !== "all") {
|
||||||
|
filters.tag = selectedTag;
|
||||||
|
}
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
filters.search = searchQuery.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadEntries();
|
||||||
|
}, [loadEntries]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalEntries / itemsPerPage));
|
||||||
|
|
||||||
// Reset to page 1 when filters change
|
// Reset to page 1 when filters change
|
||||||
const handleFilterChange = (callback: () => void): void => {
|
const handleFilterChange = (callback: () => void): void => {
|
||||||
@@ -101,6 +108,16 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
setCurrentPage(1);
|
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 (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -125,14 +142,37 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<ImportExportActions
|
<ImportExportActions
|
||||||
onImportComplete={() => {
|
onImportComplete={() => {
|
||||||
// TODO: Refresh the entry list when real API is connected
|
void loadEntries();
|
||||||
// For now, this would trigger a refetch of the entries
|
|
||||||
window.location.reload();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Filters */}
|
||||||
<EntryFilters
|
<EntryFilters
|
||||||
selectedStatus={selectedStatus}
|
selectedStatus={selectedStatus}
|
||||||
@@ -140,7 +180,7 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
tags={mockTags}
|
tags={tags}
|
||||||
onStatusChange={(status) => {
|
onStatusChange={(status) => {
|
||||||
handleFilterChange(() => {
|
handleFilterChange(() => {
|
||||||
setSelectedStatus(status);
|
setSelectedStatus(status);
|
||||||
@@ -161,7 +201,7 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
|
|
||||||
{/* Entry list */}
|
{/* Entry list */}
|
||||||
<EntryList
|
<EntryList
|
||||||
entries={paginatedEntries}
|
entries={entries}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
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,85 +1,154 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||||
import DashboardPage from "./page";
|
import DashboardPage from "./page";
|
||||||
|
import * as layoutsApi from "@/lib/api/layouts";
|
||||||
|
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
|
||||||
|
|
||||||
// Mock dashboard widgets
|
// ResizeObserver is not available in jsdom
|
||||||
vi.mock("@/components/dashboard/RecentTasksWidget", () => ({
|
beforeAll((): void => {
|
||||||
RecentTasksWidget: ({
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||||
tasks,
|
observe: vi.fn(),
|
||||||
isLoading,
|
unobserve: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock WidgetGrid to avoid react-grid-layout dependency in tests
|
||||||
|
vi.mock("@/components/widgets/WidgetGrid", () => ({
|
||||||
|
WidgetGrid: ({
|
||||||
|
layout,
|
||||||
|
isEditing,
|
||||||
}: {
|
}: {
|
||||||
tasks: unknown[];
|
layout: WidgetPlacement[];
|
||||||
isLoading: boolean;
|
isEditing?: boolean;
|
||||||
}): React.JSX.Element => (
|
}): React.JSX.Element => (
|
||||||
<div data-testid="recent-tasks">
|
<div data-testid="widget-grid" data-editing={isEditing}>
|
||||||
{isLoading ? "Loading tasks" : `${String(tasks.length)} tasks`}
|
{layout.map((item) => (
|
||||||
|
<div key={item.i} data-testid={`widget-${item.i}`}>
|
||||||
|
{item.i}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/dashboard/UpcomingEventsWidget", () => ({
|
// Mock hooks
|
||||||
UpcomingEventsWidget: ({
|
vi.mock("@/lib/hooks", () => ({
|
||||||
events,
|
useWorkspaceId: (): string | null => "ws-test-123",
|
||||||
isLoading,
|
|
||||||
}: {
|
|
||||||
events: unknown[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}): React.JSX.Element => (
|
|
||||||
<div data-testid="upcoming-events">
|
|
||||||
{isLoading ? "Loading events" : `${String(events.length)} events`}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/dashboard/QuickCaptureWidget", () => ({
|
// Mock layout API
|
||||||
QuickCaptureWidget: (): React.JSX.Element => <div data-testid="quick-capture">Quick Capture</div>,
|
vi.mock("@/lib/api/layouts");
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/dashboard/DomainOverviewWidget", () => ({
|
const mockExistingLayout: UserLayout = {
|
||||||
DomainOverviewWidget: ({
|
id: "layout-1",
|
||||||
tasks,
|
workspaceId: "ws-test-123",
|
||||||
isLoading,
|
userId: "user-1",
|
||||||
}: {
|
name: "Default",
|
||||||
tasks: unknown[];
|
isDefault: true,
|
||||||
isLoading: boolean;
|
layout: [
|
||||||
}): React.JSX.Element => (
|
{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2 },
|
||||||
<div data-testid="domain-overview">
|
{ i: "CalendarWidget-default", x: 4, y: 0, w: 4, h: 2 },
|
||||||
{isLoading ? "Loading overview" : `${String(tasks.length)} tasks overview`}
|
],
|
||||||
</div>
|
metadata: {},
|
||||||
),
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
}));
|
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
};
|
||||||
|
|
||||||
describe("DashboardPage", (): void => {
|
describe("DashboardPage", (): void => {
|
||||||
it("should render the page title", (): void => {
|
beforeEach((): void => {
|
||||||
render(<DashboardPage />);
|
vi.clearAllMocks();
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Dashboard");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show loading state initially", (): void => {
|
it("should render WidgetGrid with saved layout", async (): Promise<void> => {
|
||||||
render(<DashboardPage />);
|
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||||
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("Loading tasks");
|
|
||||||
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("Loading events");
|
|
||||||
expect(screen.getByTestId("domain-overview")).toHaveTextContent("Loading overview");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render all widgets with data after loading", async (): Promise<void> => {
|
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("4 tasks");
|
expect(screen.getByTestId("widget-grid")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("3 events");
|
|
||||||
expect(screen.getByTestId("domain-overview")).toHaveTextContent("4 tasks overview");
|
|
||||||
expect(screen.getByTestId("quick-capture")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have proper layout structure", (): void => {
|
expect(screen.getByTestId("widget-TasksWidget-default")).toBeInTheDocument();
|
||||||
const { container } = render(<DashboardPage />);
|
expect(screen.getByTestId("widget-CalendarWidget-default")).toBeInTheDocument();
|
||||||
const main = container.querySelector("main");
|
});
|
||||||
expect(main).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 }],
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render the welcome subtitle", (): void => {
|
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
expect(screen.getByText(/Welcome back/)).toBeInTheDocument();
|
|
||||||
|
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 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("widget-grid")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render Dashboard heading", async (): Promise<void> => {
|
||||||
|
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render Edit Layout button", async (): Promise<void> => {
|
||||||
|
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("Edit Layout")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle edit mode on button click", async (): Promise<void> => {
|
||||||
|
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
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,32 +1,242 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics";
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions";
|
import { WidgetGrid } from "@/components/widgets/WidgetGrid";
|
||||||
import { QuickActions } from "@/components/dashboard/QuickActions";
|
import { WidgetPicker } from "@/components/widgets/WidgetPicker";
|
||||||
import { ActivityFeed } from "@/components/dashboard/ActivityFeed";
|
import { WidgetConfigDialog } from "@/components/widgets/WidgetConfigDialog";
|
||||||
import { TokenBudget } from "@/components/dashboard/TokenBudget";
|
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 {
|
export default function DashboardPage(): ReactElement {
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsId = workspaceId;
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
async function loadLayout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
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 load layout:", err);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadLayout();
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
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 (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
|
||||||
<DashboardMetrics />
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div
|
<div
|
||||||
style={{
|
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
|
||||||
display: "grid",
|
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
|
||||||
gridTemplateColumns: "1fr 320px",
|
/>
|
||||||
gap: 16,
|
<span className="text-sm" style={{ color: "var(--muted)" }}>
|
||||||
}}
|
Loading dashboard...
|
||||||
>
|
</span>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
{/* Dashboard header with edit toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--text)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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 { 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";
|
import TasksPage from "./page";
|
||||||
|
|
||||||
// Mock the TaskList component
|
// 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 => {
|
describe("TasksPage", (): void => {
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUseWorkspaceId.mockReturnValue("ws-1");
|
||||||
|
mockFetchTasks.mockResolvedValue(fakeTasks);
|
||||||
|
});
|
||||||
|
|
||||||
it("should render the page title", (): void => {
|
it("should render the page title", (): void => {
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks");
|
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 />);
|
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> => {
|
it("should render the TaskList with tasks after loading", async (): Promise<void> => {
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
await waitFor((): void => {
|
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 />);
|
render(<TasksPage />);
|
||||||
expect(screen.getByText("Organize your work at your own pace")).toBeInTheDocument();
|
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 type { ReactElement } from "react";
|
||||||
|
|
||||||
import { TaskList } from "@/components/tasks/TaskList";
|
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";
|
import type { Task } from "@mosaic/shared";
|
||||||
|
|
||||||
export default function TasksPage(): ReactElement {
|
export default function TasksPage(): ReactElement {
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadTasks();
|
if (!workspaceId) {
|
||||||
}, []);
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
async function loadTasks(): Promise<void> {
|
async function loadTasks(): Promise<void> {
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Replace with real API call when backend is ready
|
const filters = workspaceId !== null ? { workspaceId } : {};
|
||||||
// const data = await fetchTasks();
|
const data = await fetchTasks(filters);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
if (!cancelled) {
|
||||||
setTasks(mockTasks);
|
setTasks(data);
|
||||||
} catch (err) {
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Tasks] Failed to fetch tasks:", err);
|
||||||
|
if (!cancelled) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: "We had trouble loading your tasks. Please try again when you're ready."
|
: "We had trouble loading your tasks. Please try again when you're ready."
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
setIsLoading(false);
|
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 (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Tasks</h1>
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
<p className="text-gray-600 mt-2">Organize your work at your own pace</p>
|
Tasks
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Organize your work at your own pace
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error !== null ? (
|
{isLoading ? (
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
<div className="flex justify-center py-16">
|
||||||
<p className="text-amber-800">{error}</p>
|
<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
|
<button
|
||||||
onClick={() => void loadTasks()}
|
onClick={handleRetry}
|
||||||
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
className="mt-4 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||||
|
style={{ background: "var(--danger)" }}
|
||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</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,62 @@ body::before {
|
|||||||
animation: scaleIn 0.1s ease-out;
|
animation: scaleIn 0.1s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Dashboard Layout — Responsive Grids
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
.metrics-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--ms-cols, 6), 1fr);
|
||||||
|
gap: 0;
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.metrics-strip {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell:nth-child(3n + 1) {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.metrics-strip {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell:nth-child(3n + 1) {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell:nth-child(2n + 1) {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.dash-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
Responsive Typography Adjustments
|
Responsive Typography Adjustments
|
||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Card, SectionHeader, Badge } from "@mosaic/ui";
|
import { Card, SectionHeader, Badge } from "@mosaic/ui";
|
||||||
|
import type { RecentActivity } from "@/lib/api/dashboard";
|
||||||
|
|
||||||
type BadgeVariantType =
|
type BadgeVariantType =
|
||||||
| "badge-amber"
|
| "badge-amber"
|
||||||
@@ -10,7 +11,7 @@ type BadgeVariantType =
|
|||||||
| "badge-purple"
|
| "badge-purple"
|
||||||
| "badge-pulse";
|
| "badge-pulse";
|
||||||
|
|
||||||
interface ActivityItem {
|
interface ActivityDisplayItem {
|
||||||
id: string;
|
id: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
iconBg: string;
|
iconBg: string;
|
||||||
@@ -18,82 +19,91 @@ interface ActivityItem {
|
|||||||
highlight: string;
|
highlight: string;
|
||||||
rest: string;
|
rest: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
badge?: {
|
badge?:
|
||||||
|
| {
|
||||||
text: string;
|
text: string;
|
||||||
variant: BadgeVariantType;
|
variant: BadgeVariantType;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityFeedProps {
|
||||||
|
items?: RecentActivity[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Mapping helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function getIconForAction(action: string): { icon: string; iconBg: string } {
|
||||||
|
const lower = action.toLowerCase();
|
||||||
|
if (lower.includes("complet") || lower.includes("finish") || lower.includes("success")) {
|
||||||
|
return { icon: "\u2713", iconBg: "rgba(20,184,166,0.15)" };
|
||||||
|
}
|
||||||
|
if (lower.includes("fail") || lower.includes("error")) {
|
||||||
|
return { icon: "\u2717", iconBg: "rgba(229,72,77,0.15)" };
|
||||||
|
}
|
||||||
|
if (lower.includes("warn") || lower.includes("limit")) {
|
||||||
|
return { icon: "\u26A0", iconBg: "rgba(245,158,11,0.15)" };
|
||||||
|
}
|
||||||
|
if (lower.includes("start") || lower.includes("creat")) {
|
||||||
|
return { icon: "\u2191", iconBg: "rgba(47,128,255,0.15)" };
|
||||||
|
}
|
||||||
|
if (lower.includes("update") || lower.includes("modif")) {
|
||||||
|
return { icon: "\u21BB", iconBg: "rgba(139,92,246,0.15)" };
|
||||||
|
}
|
||||||
|
return { icon: "\u2022", iconBg: "rgba(100,116,139,0.15)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBadgeForAction(action: string): ActivityDisplayItem["badge"] {
|
||||||
|
const lower = action.toLowerCase();
|
||||||
|
if (lower.includes("fail") || lower.includes("error")) {
|
||||||
|
return { text: "error", variant: "badge-red" };
|
||||||
|
}
|
||||||
|
if (lower.includes("warn") || lower.includes("limit")) {
|
||||||
|
return { text: "warn", variant: "badge-amber" };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(isoDate: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(isoDate).getTime();
|
||||||
|
const diffMs = now - then;
|
||||||
|
|
||||||
|
if (Number.isNaN(diffMs) || diffMs < 0) return "just now";
|
||||||
|
|
||||||
|
const minutes = Math.floor(diffMs / 60_000);
|
||||||
|
if (minutes < 1) return "just now";
|
||||||
|
if (minutes < 60) return `${String(minutes)}m ago`;
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${String(hours)}h ago`;
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${String(days)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapActivityToDisplay(activity: RecentActivity): ActivityDisplayItem {
|
||||||
|
const { icon, iconBg } = getIconForAction(activity.action);
|
||||||
|
return {
|
||||||
|
id: activity.id,
|
||||||
|
icon,
|
||||||
|
iconBg,
|
||||||
|
title: "",
|
||||||
|
highlight: activity.entityType,
|
||||||
|
rest: ` ${activity.action} (${activity.entityId})`,
|
||||||
|
timestamp: formatRelativeTime(activity.createdAt),
|
||||||
|
badge: getBadgeForAction(activity.action),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const activityItems: ActivityItem[] = [
|
/* ------------------------------------------------------------------ */
|
||||||
{
|
/* Components */
|
||||||
id: "act-1",
|
/* ------------------------------------------------------------------ */
|
||||||
icon: "✓",
|
|
||||||
iconBg: "rgba(20,184,166,0.15)",
|
|
||||||
title: "",
|
|
||||||
highlight: "planner-agent",
|
|
||||||
rest: " completed task analysis for infra-refactor",
|
|
||||||
timestamp: "2m ago",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "act-2",
|
|
||||||
icon: "⚠",
|
|
||||||
iconBg: "rgba(245,158,11,0.15)",
|
|
||||||
title: "",
|
|
||||||
highlight: "executor-agent",
|
|
||||||
rest: " hit rate limit on Terraform API",
|
|
||||||
timestamp: "5m ago",
|
|
||||||
badge: { text: "warn", variant: "badge-amber" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "act-3",
|
|
||||||
icon: "↑",
|
|
||||||
iconBg: "rgba(47,128,255,0.15)",
|
|
||||||
title: "",
|
|
||||||
highlight: "ORCH-002",
|
|
||||||
rest: " session started for api-v3-migration",
|
|
||||||
timestamp: "12m ago",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "act-4",
|
|
||||||
icon: "✗",
|
|
||||||
iconBg: "rgba(229,72,77,0.15)",
|
|
||||||
title: "",
|
|
||||||
highlight: "migrator-agent",
|
|
||||||
rest: " failed to connect to staging database",
|
|
||||||
timestamp: "18m ago",
|
|
||||||
badge: { text: "error", variant: "badge-red" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "act-5",
|
|
||||||
icon: "✓",
|
|
||||||
iconBg: "rgba(20,184,166,0.15)",
|
|
||||||
title: "",
|
|
||||||
highlight: "reviewer-agent",
|
|
||||||
rest: " approved PR #214 in infra-refactor",
|
|
||||||
timestamp: "34m ago",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "act-6",
|
|
||||||
icon: "⟳",
|
|
||||||
iconBg: "rgba(139,92,246,0.15)",
|
|
||||||
title: "Token budget reset for ",
|
|
||||||
highlight: "gpt-4o",
|
|
||||||
rest: " model",
|
|
||||||
timestamp: "1h ago",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "act-7",
|
|
||||||
icon: "★",
|
|
||||||
iconBg: "rgba(20,184,166,0.15)",
|
|
||||||
title: "Project ",
|
|
||||||
highlight: "data-pipeline",
|
|
||||||
rest: " marked as completed",
|
|
||||||
timestamp: "2h ago",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ActivityItemRowProps {
|
interface ActivityItemRowProps {
|
||||||
item: ActivityItem;
|
item: ActivityDisplayItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
||||||
@@ -102,8 +112,8 @@ function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
|||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
gap: 10,
|
gap: 12,
|
||||||
padding: "8px 0",
|
padding: "10px 0",
|
||||||
borderBottom: "1px solid var(--border)",
|
borderBottom: "1px solid var(--border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -155,14 +165,27 @@ function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityFeed(): ReactElement {
|
export function ActivityFeed({ items }: ActivityFeedProps): ReactElement {
|
||||||
|
const displayItems = items ? items.map(mapActivityToDisplay) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<SectionHeader title="Activity Feed" subtitle="Recent agent events" />
|
<SectionHeader title="Activity Feed" subtitle="Recent agent events" />
|
||||||
<div>
|
<div>
|
||||||
{activityItems.map((item) => (
|
{displayItems.length > 0 ? (
|
||||||
<ActivityItemRow key={item.id} item={item} />
|
displayItems.map((item) => <ActivityItemRow key={item.id} item={item} />)
|
||||||
))}
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No recent activity
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +1,69 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { MetricsStrip, type MetricCell } from "@mosaic/ui";
|
import { MetricsStrip, type MetricCell } from "@mosaic/ui";
|
||||||
|
import type { DashboardMetrics as DashboardMetricsData } from "@/lib/api/dashboard";
|
||||||
|
|
||||||
const cells: MetricCell[] = [
|
export interface DashboardMetricsProps {
|
||||||
|
metrics?: DashboardMetricsData | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCells(metrics: DashboardMetricsData): MetricCell[] {
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
label: "Active Agents",
|
label: "Active Agents",
|
||||||
value: "47",
|
value: formatNumber(metrics.activeAgents),
|
||||||
color: "var(--ms-blue-400)",
|
color: "var(--ms-blue-400)",
|
||||||
trend: { direction: "up", text: "↑ +3 from yesterday" },
|
trend: { direction: "neutral", text: "currently active" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Tasks Completed",
|
label: "Tasks Completed",
|
||||||
value: "1,284",
|
value: formatNumber(metrics.tasksCompleted),
|
||||||
color: "var(--ms-teal-400)",
|
color: "var(--ms-teal-400)",
|
||||||
trend: { direction: "up", text: "↑ +128 today" },
|
trend: { direction: "neutral", text: `of ${formatNumber(metrics.totalTasks)} total` },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Avg Response Time",
|
label: "Total Tasks",
|
||||||
value: "2.4s",
|
value: formatNumber(metrics.totalTasks),
|
||||||
color: "var(--ms-purple-400)",
|
color: "var(--ms-purple-400)",
|
||||||
trend: { direction: "down", text: "↓ -0.3s improved" },
|
trend: { direction: "neutral", text: "across workspace" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Token Usage",
|
label: "In Progress",
|
||||||
value: "3.2M",
|
value: formatNumber(metrics.tasksInProgress),
|
||||||
color: "var(--ms-amber-400)",
|
color: "var(--ms-amber-400)",
|
||||||
trend: { direction: "neutral", text: "78% of budget" },
|
trend: { direction: "neutral", text: "tasks running" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Error Rate",
|
label: "Error Rate",
|
||||||
value: "0.4%",
|
value: `${String(metrics.errorRate)}%`,
|
||||||
color: "var(--ms-red-400)",
|
color: "var(--ms-red-400)",
|
||||||
trend: { direction: "down", text: "↓ -0.1% improved" },
|
trend: {
|
||||||
|
direction: metrics.errorRate > 1 ? "up" : "down",
|
||||||
|
text: metrics.errorRate > 1 ? "above threshold" : "within threshold",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Active Projects",
|
label: "Active Projects",
|
||||||
value: "8",
|
value: formatNumber(metrics.activeProjects),
|
||||||
color: "var(--ms-cyan-500)",
|
color: "var(--ms-cyan-500)",
|
||||||
trend: { direction: "neutral", text: "2 deploying" },
|
trend: { direction: "neutral", text: "in workspace" },
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_CELLS: MetricCell[] = [
|
||||||
|
{ label: "Active Agents", value: "0", color: "var(--ms-blue-400)" },
|
||||||
|
{ label: "Tasks Completed", value: "0", color: "var(--ms-teal-400)" },
|
||||||
|
{ label: "Total Tasks", value: "0", color: "var(--ms-purple-400)" },
|
||||||
|
{ label: "In Progress", value: "0", color: "var(--ms-amber-400)" },
|
||||||
|
{ label: "Error Rate", value: "0%", color: "var(--ms-red-400)" },
|
||||||
|
{ label: "Active Projects", value: "0", color: "var(--ms-cyan-500)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DashboardMetrics(): ReactElement {
|
export function DashboardMetrics({ metrics }: DashboardMetricsProps): ReactElement {
|
||||||
|
const cells = metrics ? buildCells(metrics) : EMPTY_CELLS;
|
||||||
return <MetricsStrip cells={cells} />;
|
return <MetricsStrip cells={cells} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import type { Task } from "@mosaic/shared";
|
|
||||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
|
||||||
|
|
||||||
interface DomainOverviewWidgetProps {
|
|
||||||
tasks: Task[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DomainOverviewWidget({
|
|
||||||
tasks,
|
|
||||||
isLoading,
|
|
||||||
}: DomainOverviewWidgetProps): React.JSX.Element {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex justify-center items-center">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
|
||||||
<span className="ml-3 text-gray-600">Loading overview...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
total: tasks.length,
|
|
||||||
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
|
|
||||||
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
|
|
||||||
highPriority: tasks.filter((t) => t.priority === TaskPriority.HIGH).length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const StatCard = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
color: string;
|
|
||||||
}): React.JSX.Element => (
|
|
||||||
<div className={`p-4 rounded-lg bg-gradient-to-br ${color}`}>
|
|
||||||
<div className="text-3xl font-bold text-white mb-1">{value}</div>
|
|
||||||
<div className="text-sm text-white/90">{label}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Domain Overview</h2>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<StatCard label="Total Tasks" value={stats.total} color="from-blue-500 to-blue-600" />
|
|
||||||
<StatCard
|
|
||||||
label="In Progress"
|
|
||||||
value={stats.inProgress}
|
|
||||||
color="from-green-500 to-green-600"
|
|
||||||
/>
|
|
||||||
<StatCard label="Completed" value={stats.completed} color="from-purple-500 to-purple-600" />
|
|
||||||
<StatCard
|
|
||||||
label="High Priority"
|
|
||||||
value={stats.highPriority}
|
|
||||||
color="from-red-500 to-red-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,22 +3,15 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Card, SectionHeader, Badge, Dot } from "@mosaic/ui";
|
import { Card, SectionHeader, Badge, Dot } from "@mosaic/ui";
|
||||||
|
import type { ActiveJob } from "@/lib/api/dashboard";
|
||||||
|
|
||||||
interface AgentNode {
|
/* ------------------------------------------------------------------ */
|
||||||
id: string;
|
/* Internal display types */
|
||||||
initials: string;
|
/* ------------------------------------------------------------------ */
|
||||||
avatarColor: string;
|
|
||||||
name: string;
|
|
||||||
task: string;
|
|
||||||
status: "teal" | "blue" | "amber" | "red" | "muted";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrchestratorSession {
|
type DotVariant = "teal" | "blue" | "amber" | "red" | "muted";
|
||||||
id: string;
|
|
||||||
orchId: string;
|
type BadgeVariant =
|
||||||
name: string;
|
|
||||||
badge: string;
|
|
||||||
badgeVariant:
|
|
||||||
| "badge-teal"
|
| "badge-teal"
|
||||||
| "badge-amber"
|
| "badge-amber"
|
||||||
| "badge-red"
|
| "badge-red"
|
||||||
@@ -26,65 +19,113 @@ interface OrchestratorSession {
|
|||||||
| "badge-muted"
|
| "badge-muted"
|
||||||
| "badge-purple"
|
| "badge-purple"
|
||||||
| "badge-pulse";
|
| "badge-pulse";
|
||||||
|
|
||||||
|
interface AgentNode {
|
||||||
|
id: string;
|
||||||
|
initials: string;
|
||||||
|
avatarColor: string;
|
||||||
|
name: string;
|
||||||
|
task: string;
|
||||||
|
status: DotVariant;
|
||||||
|
statusLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrchestratorSession {
|
||||||
|
id: string;
|
||||||
|
orchId: string;
|
||||||
|
name: string;
|
||||||
|
badge: string;
|
||||||
|
badgeVariant: BadgeVariant;
|
||||||
duration: string;
|
duration: string;
|
||||||
|
progress: number;
|
||||||
agents: AgentNode[];
|
agents: AgentNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions: OrchestratorSession[] = [
|
export interface OrchestratorSessionsProps {
|
||||||
{
|
jobs?: ActiveJob[] | undefined;
|
||||||
id: "s1",
|
}
|
||||||
orchId: "ORCH-001",
|
|
||||||
name: "infra-refactor",
|
/* ------------------------------------------------------------------ */
|
||||||
badge: "running",
|
/* Mapping helpers */
|
||||||
badgeVariant: "badge-teal",
|
/* ------------------------------------------------------------------ */
|
||||||
duration: "2h 14m",
|
|
||||||
agents: [
|
const STEP_COLORS: string[] = [
|
||||||
{
|
"rgba(47,128,255,0.15)",
|
||||||
id: "a1",
|
"rgba(20,184,166,0.15)",
|
||||||
initials: "PL",
|
"rgba(245,158,11,0.15)",
|
||||||
avatarColor: "rgba(47,128,255,0.15)",
|
"rgba(139,92,246,0.15)",
|
||||||
name: "planner-agent",
|
"rgba(229,72,77,0.15)",
|
||||||
task: "Analyzing network topology",
|
|
||||||
status: "blue",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "a2",
|
|
||||||
initials: "EX",
|
|
||||||
avatarColor: "rgba(20,184,166,0.15)",
|
|
||||||
name: "executor-agent",
|
|
||||||
task: "Applying Terraform modules",
|
|
||||||
status: "teal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "a3",
|
|
||||||
initials: "QA",
|
|
||||||
avatarColor: "rgba(245,158,11,0.15)",
|
|
||||||
name: "reviewer-agent",
|
|
||||||
task: "Waiting for executor output",
|
|
||||||
status: "amber",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "s2",
|
|
||||||
orchId: "ORCH-002",
|
|
||||||
name: "api-v3-migration",
|
|
||||||
badge: "running",
|
|
||||||
badgeVariant: "badge-teal",
|
|
||||||
duration: "45m",
|
|
||||||
agents: [
|
|
||||||
{
|
|
||||||
id: "a4",
|
|
||||||
initials: "MG",
|
|
||||||
avatarColor: "rgba(139,92,246,0.15)",
|
|
||||||
name: "migrator-agent",
|
|
||||||
task: "Rewriting endpoint handlers",
|
|
||||||
status: "blue",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function statusToDotVariant(status: string): DotVariant {
|
||||||
|
const lower = status.toLowerCase();
|
||||||
|
if (lower === "running" || lower === "active" || lower === "completed") return "teal";
|
||||||
|
if (lower === "pending" || lower === "queued") return "blue";
|
||||||
|
if (lower === "waiting" || lower === "paused") return "amber";
|
||||||
|
if (lower === "failed" || lower === "error") return "red";
|
||||||
|
return "muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusToBadgeVariant(status: string): BadgeVariant {
|
||||||
|
const lower = status.toLowerCase();
|
||||||
|
if (lower === "running" || lower === "active") return "badge-teal";
|
||||||
|
if (lower === "pending" || lower === "queued") return "badge-blue";
|
||||||
|
if (lower === "waiting" || lower === "paused") return "badge-amber";
|
||||||
|
if (lower === "failed" || lower === "error") return "badge-red";
|
||||||
|
if (lower === "completed") return "badge-purple";
|
||||||
|
return "badge-muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(isoDate: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const start = new Date(isoDate).getTime();
|
||||||
|
const diffMs = now - start;
|
||||||
|
|
||||||
|
if (Number.isNaN(diffMs) || diffMs < 0) return "0m";
|
||||||
|
|
||||||
|
const totalMinutes = Math.floor(diffMs / 60_000);
|
||||||
|
if (totalMinutes < 60) return `${String(totalMinutes)}m`;
|
||||||
|
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return `${String(hours)}h ${String(minutes)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/[\s\-_]+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w.charAt(0).toUpperCase())
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapJobToSession(job: ActiveJob): OrchestratorSession {
|
||||||
|
const agents: AgentNode[] = job.steps.map((step, idx) => ({
|
||||||
|
id: step.id,
|
||||||
|
initials: initials(step.name),
|
||||||
|
avatarColor: STEP_COLORS[idx % STEP_COLORS.length] ?? "rgba(100,116,139,0.15)",
|
||||||
|
name: step.name,
|
||||||
|
task: `Phase: ${step.phase}`,
|
||||||
|
status: statusToDotVariant(step.status),
|
||||||
|
statusLabel: step.status.toLowerCase(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
orchId: job.id.length > 10 ? job.id.slice(0, 10).toUpperCase() : job.id.toUpperCase(),
|
||||||
|
name: job.type,
|
||||||
|
badge: job.status,
|
||||||
|
badgeVariant: statusToBadgeVariant(job.status),
|
||||||
|
duration: formatDuration(job.createdAt),
|
||||||
|
progress: job.progressPercent,
|
||||||
|
agents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Sub-components */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
interface AgentNodeItemProps {
|
interface AgentNodeItemProps {
|
||||||
agent: AgentNode;
|
agent: AgentNode;
|
||||||
}
|
}
|
||||||
@@ -155,6 +196,16 @@ function AgentNodeItem({ agent }: AgentNodeItemProps): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Dot variant={agent.status} />
|
<Dot variant={agent.status} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.statusLabel}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -169,7 +220,7 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
|||||||
style={{
|
style={{
|
||||||
background: "var(--bg-mid)",
|
background: "var(--bg-mid)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "var(--r-md)",
|
borderRadius: "var(--r)",
|
||||||
padding: "12px 14px",
|
padding: "12px 14px",
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
@@ -182,7 +233,7 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
|||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dot variant="teal" />
|
<Dot variant={statusToDotVariant(session.badge)} />
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--mono)",
|
fontFamily: "var(--mono)",
|
||||||
@@ -214,6 +265,27 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
|||||||
{session.duration}
|
{session.duration}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
{session.agents.map((agent) => (
|
{session.agents.map((agent) => (
|
||||||
<AgentNodeItem key={agent.id} agent={agent} />
|
<AgentNodeItem key={agent.id} agent={agent} />
|
||||||
@@ -223,18 +295,48 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OrchestratorSessions(): ReactElement {
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main export */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function OrchestratorSessions({ jobs }: OrchestratorSessionsProps): ReactElement {
|
||||||
|
const sessions = jobs ? jobs.map(mapJobToSession) : [];
|
||||||
|
const activeCount = jobs
|
||||||
|
? jobs.filter(
|
||||||
|
(j) => j.status.toLowerCase() === "running" || j.status.toLowerCase() === "active"
|
||||||
|
).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Active Orchestrator Sessions"
|
title="Active Orchestrator Sessions"
|
||||||
subtitle="3 of 8 projects running"
|
subtitle={
|
||||||
actions={<Badge variant="badge-teal">3 active</Badge>}
|
sessions.length > 0
|
||||||
|
? `${String(activeCount)} of ${String(sessions.length)} jobs running`
|
||||||
|
: "No active sessions"
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
sessions.length > 0 ? (
|
||||||
|
<Badge variant="badge-teal">{String(activeCount)} active</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
{sessions.map((session) => (
|
{sessions.length > 0 ? (
|
||||||
<OrchCard key={session.id} session={session} />
|
sessions.map((session) => <OrchCard key={session.id} session={session} />)
|
||||||
))}
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No active sessions
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ interface QuickAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actions: QuickAction[] = [
|
const actions: QuickAction[] = [
|
||||||
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.15)" },
|
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.12)" },
|
||||||
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.15)" },
|
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.12)" },
|
||||||
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.15)" },
|
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.12)" },
|
||||||
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,0.15)" },
|
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,0.12)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ActionButtonProps {
|
interface ActionButtonProps {
|
||||||
@@ -36,24 +36,25 @@ function ActionButton({ action }: ActionButtonProps): ReactElement {
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
|
||||||
gap: 8,
|
gap: 8,
|
||||||
padding: "16px 12px",
|
padding: "10px 12px",
|
||||||
borderRadius: "var(--r-md)",
|
borderRadius: "var(--r)",
|
||||||
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
|
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
|
||||||
background: hovered ? "var(--surface)" : "var(--bg-mid)",
|
background: hovered ? "var(--surface)" : "var(--bg-mid)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
transition: "border-color 0.15s, background 0.15s",
|
transition: "border-color 0.15s, background 0.15s, color 0.15s",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: hovered ? "var(--text)" : "var(--text-2)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
borderRadius: 6,
|
borderRadius: 5,
|
||||||
background: action.iconBg,
|
background: action.iconBg,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -63,15 +64,7 @@ function ActionButton({ action }: ActionButtonProps): ReactElement {
|
|||||||
>
|
>
|
||||||
{action.icon}
|
{action.icon}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span>{action.label}</span>
|
||||||
style={{
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -84,7 +77,7 @@ export function QuickActions(): ReactElement {
|
|||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr",
|
gridTemplateColumns: "1fr 1fr",
|
||||||
gap: 10,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{actions.map((action) => (
|
{actions.map((action) => (
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@mosaic/ui";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ComingSoon } from "@/components/ui/ComingSoon";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if we're in development mode (runtime check for testability)
|
|
||||||
*/
|
|
||||||
function isDevelopment(): boolean {
|
|
||||||
return process.env.NODE_ENV === "development";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal Quick Capture Widget implementation
|
|
||||||
*/
|
|
||||||
function QuickCaptureWidgetInternal(): React.JSX.Element {
|
|
||||||
const [idea, setIdea] = useState("");
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!idea.trim()) return;
|
|
||||||
|
|
||||||
// TODO: Implement quick capture API call
|
|
||||||
// For now, just show a success indicator
|
|
||||||
console.log("Quick capture:", idea);
|
|
||||||
setIdea("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToTasks = (): void => {
|
|
||||||
router.push("/tasks");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Quick Capture</h2>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">Quickly jot down ideas or brain dumps</p>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
|
||||||
<textarea
|
|
||||||
value={idea}
|
|
||||||
onChange={(e) => {
|
|
||||||
setIdea(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="What's on your mind?"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button type="submit" variant="primary" size="sm">
|
|
||||||
Save Note
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={goToTasks}>
|
|
||||||
Create Task
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quick Capture Widget (Dashboard version)
|
|
||||||
*
|
|
||||||
* In production: Shows Coming Soon placeholder
|
|
||||||
* In development: Full widget functionality
|
|
||||||
*/
|
|
||||||
export function QuickCaptureWidget(): React.JSX.Element {
|
|
||||||
// In production, show Coming Soon placeholder
|
|
||||||
if (!isDevelopment()) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<ComingSoon
|
|
||||||
feature="Quick Capture"
|
|
||||||
description="Quickly jot down ideas for later organization. This feature is currently under development."
|
|
||||||
className="!p-0 !min-h-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In development, show full widget functionality
|
|
||||||
return <QuickCaptureWidgetInternal />;
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { Task } from "@mosaic/shared";
|
|
||||||
import { TaskPriority } from "@mosaic/shared";
|
|
||||||
import { formatDate } from "@/lib/utils/date-format";
|
|
||||||
import { TaskStatus } from "@mosaic/shared";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface RecentTasksWidgetProps {
|
|
||||||
tasks: Task[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusIcons: Record<TaskStatus, string> = {
|
|
||||||
[TaskStatus.NOT_STARTED]: "⚪",
|
|
||||||
[TaskStatus.IN_PROGRESS]: "🟢",
|
|
||||||
[TaskStatus.PAUSED]: "⏸️",
|
|
||||||
[TaskStatus.COMPLETED]: "✅",
|
|
||||||
[TaskStatus.ARCHIVED]: "💤",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RecentTasksWidget({ tasks, isLoading }: RecentTasksWidgetProps): React.JSX.Element {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex justify-center items-center">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
|
||||||
<span className="ml-3 text-gray-600">Loading tasks...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentTasks = tasks.slice(0, 5);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Recent Tasks</h2>
|
|
||||||
<Link href="/tasks" className="text-sm text-blue-600 hover:text-blue-700">
|
|
||||||
View all →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{recentTasks.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-500 text-center py-4">No tasks yet</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{recentTasks.map((task) => (
|
|
||||||
<li
|
|
||||||
key={task.id}
|
|
||||||
className="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-lg flex-shrink-0" aria-label={`Status: ${task.status}`}>
|
|
||||||
{statusIcons[task.status]}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-medium text-gray-900 text-sm truncate">{task.title}</h3>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
{task.priority !== TaskPriority.LOW && (
|
|
||||||
<span
|
|
||||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
|
||||||
task.priority === TaskPriority.HIGH
|
|
||||||
? "bg-red-100 text-red-700"
|
|
||||||
: "bg-blue-100 text-blue-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{task.priority}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{task.dueDate && (
|
|
||||||
<span className="text-xs text-gray-500">{formatDate(task.dueDate)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,24 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Card, SectionHeader, ProgressBar, type ProgressBarVariant } from "@mosaic/ui";
|
import { Card, SectionHeader, ProgressBar, type ProgressBarVariant } from "@mosaic/ui";
|
||||||
|
import type { TokenBudgetEntry } from "@/lib/api/dashboard";
|
||||||
|
|
||||||
interface ModelBudget {
|
export interface TokenBudgetProps {
|
||||||
|
budgets?: TokenBudgetEntry[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const VARIANT_CYCLE: ProgressBarVariant[] = ["blue", "teal", "purple", "amber"];
|
||||||
|
|
||||||
|
function formatTokenCount(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelBudgetDisplay {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
usage: string;
|
usage: string;
|
||||||
@@ -9,39 +26,28 @@ interface ModelBudget {
|
|||||||
variant: ProgressBarVariant;
|
variant: ProgressBarVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
const models: ModelBudget[] = [
|
function mapBudgetToDisplay(entry: TokenBudgetEntry, index: number): ModelBudgetDisplay {
|
||||||
{
|
const percent = entry.limit > 0 ? Math.round((entry.used / entry.limit) * 100) : 0;
|
||||||
id: "sonnet",
|
const usage =
|
||||||
label: "claude-3-5-sonnet",
|
entry.limit > 0
|
||||||
usage: "2.1M / 3M",
|
? `${formatTokenCount(entry.used)} / ${formatTokenCount(entry.limit)}`
|
||||||
value: 70,
|
: "unlimited";
|
||||||
variant: "blue",
|
|
||||||
},
|
return {
|
||||||
{
|
id: entry.model,
|
||||||
id: "haiku",
|
label: entry.model,
|
||||||
label: "claude-3-haiku",
|
usage,
|
||||||
usage: "890K / 5M",
|
value: percent,
|
||||||
value: 18,
|
variant: VARIANT_CYCLE[index % VARIANT_CYCLE.length] ?? "blue",
|
||||||
variant: "teal",
|
};
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: "gpt4o",
|
/* ------------------------------------------------------------------ */
|
||||||
label: "gpt-4o",
|
/* Components */
|
||||||
usage: "320K / 1M",
|
/* ------------------------------------------------------------------ */
|
||||||
value: 32,
|
|
||||||
variant: "purple",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "llama",
|
|
||||||
label: "local/llama-3.3",
|
|
||||||
usage: "unlimited",
|
|
||||||
value: 55,
|
|
||||||
variant: "amber",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ModelRowProps {
|
interface ModelRowProps {
|
||||||
model: ModelBudget;
|
model: ModelBudgetDisplay;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelRow({ model }: ModelRowProps): ReactElement {
|
function ModelRow({ model }: ModelRowProps): ReactElement {
|
||||||
@@ -84,14 +90,27 @@ function ModelRow({ model }: ModelRowProps): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TokenBudget(): ReactElement {
|
export function TokenBudget({ budgets }: TokenBudgetProps): ReactElement {
|
||||||
|
const displayModels = budgets ? budgets.map(mapBudgetToDisplay) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<SectionHeader title="Token Budget" subtitle="Usage by model" />
|
<SectionHeader title="Token Budget" subtitle="Usage by model" />
|
||||||
<div>
|
<div>
|
||||||
{models.map((model) => (
|
{displayModels.length > 0 ? (
|
||||||
<ModelRow key={model.id} model={model} />
|
displayModels.map((model) => <ModelRow key={model.id} model={model} />)
|
||||||
))}
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No budget data
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import type { Event } from "@mosaic/shared";
|
|
||||||
import { formatTime, formatDate } from "@/lib/utils/date-format";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface UpcomingEventsWidgetProps {
|
|
||||||
events: Event[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UpcomingEventsWidget({
|
|
||||||
events,
|
|
||||||
isLoading,
|
|
||||||
}: UpcomingEventsWidgetProps): React.JSX.Element {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex justify-center items-center">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
|
||||||
<span className="ml-3 text-gray-600">Loading events...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const upcomingEvents = events.slice(0, 4);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Upcoming Events</h2>
|
|
||||||
<Link href="/calendar" className="text-sm text-blue-600 hover:text-blue-700">
|
|
||||||
View calendar →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{upcomingEvents.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-500 text-center py-4">No upcoming events</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{upcomingEvents.map((event) => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className="flex items-start gap-3 p-3 rounded-lg border-l-4 border-blue-500 bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0 text-center min-w-[3.5rem]">
|
|
||||||
<div className="text-xs text-gray-500 uppercase font-semibold">
|
|
||||||
{formatDate(event.startTime).split(",")[0]}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-gray-900">
|
|
||||||
{formatTime(event.startTime)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-medium text-gray-900 text-sm truncate">{event.title}</h3>
|
|
||||||
{event.location && (
|
|
||||||
<p className="text-xs text-gray-500 mt-0.5">📍 {event.location}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* QuickCaptureWidget (Dashboard) Component Tests
|
|
||||||
* Tests environment-based behavior
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { QuickCaptureWidget } from "../QuickCaptureWidget";
|
|
||||||
|
|
||||||
// Mock next/navigation
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: (): { push: () => void } => ({
|
|
||||||
push: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("QuickCaptureWidget (Dashboard)", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach((): void => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Development mode", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.stubEnv("NODE_ENV", "development");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the widget form in development", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
// Should show the header
|
|
||||||
expect(screen.getByText("Quick Capture")).toBeInTheDocument();
|
|
||||||
// Should show the textarea
|
|
||||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
|
||||||
// Should show the Save Note button
|
|
||||||
expect(screen.getByRole("button", { name: /save note/i })).toBeInTheDocument();
|
|
||||||
// Should show the Create Task button
|
|
||||||
expect(screen.getByRole("button", { name: /create task/i })).toBeInTheDocument();
|
|
||||||
// Should NOT show Coming Soon badge
|
|
||||||
expect(screen.queryByText("Coming Soon")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have a placeholder for the textarea", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
const textarea = screen.getByRole("textbox");
|
|
||||||
expect(textarea).toHaveAttribute("placeholder", "What's on your mind?");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Production mode", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.stubEnv("NODE_ENV", "production");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show Coming Soon placeholder in production", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
// Should show Coming Soon badge
|
|
||||||
expect(screen.getByText("Coming Soon")).toBeInTheDocument();
|
|
||||||
// Should show feature name
|
|
||||||
expect(screen.getByText("Quick Capture")).toBeInTheDocument();
|
|
||||||
// Should NOT show the textarea
|
|
||||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
|
||||||
// Should NOT show the buttons
|
|
||||||
expect(screen.queryByRole("button", { name: /save note/i })).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("button", { name: /create task/i })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show description in Coming Soon placeholder", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/jot down ideas for later organization/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Test mode (non-development)", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.stubEnv("NODE_ENV", "test");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show Coming Soon placeholder in test mode", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
// Test mode is not development, so should show Coming Soon
|
|
||||||
expect(screen.getByText("Coming Soon")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef } from "react";
|
import React from "react";
|
||||||
import { LinkAutocomplete } from "./LinkAutocomplete";
|
import { KnowledgeEditor } from "./KnowledgeEditor";
|
||||||
|
|
||||||
interface EntryEditorProps {
|
interface EntryEditorProps {
|
||||||
content: string;
|
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 {
|
export function EntryEditor({ content, onChange }: EntryEditorProps): React.JSX.Element {
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="entry-editor relative">
|
<div className="entry-editor">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<label className="block text-sm font-medium mb-2" style={{ color: "var(--text-2)" }}>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
Content
|
||||||
Content (Markdown)
|
|
||||||
</label>
|
</label>
|
||||||
<button
|
<KnowledgeEditor
|
||||||
type="button"
|
content={content}
|
||||||
onClick={() => {
|
onChange={onChange}
|
||||||
setShowPreview(!showPreview);
|
placeholder="Write your content here... Supports markdown formatting."
|
||||||
}}
|
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
||||||
import { EntryCard } from "./EntryCard";
|
import { EntryCard } from "./EntryCard";
|
||||||
import { BookOpen } from "lucide-react";
|
import { BookOpen } from "lucide-react";
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
|
|
||||||
interface EntryListProps {
|
interface EntryListProps {
|
||||||
entries: KnowledgeEntryWithTags[];
|
entries: KnowledgeEntryWithTags[];
|
||||||
@@ -20,18 +21,22 @@ export function EntryList({
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center p-12">
|
<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>
|
<MosaicSpinner size={36} label="Loading entries..." />
|
||||||
<span className="ml-3 text-gray-600">Loading entries...</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center p-12 bg-white rounded-lg shadow-sm border border-gray-200">
|
<div
|
||||||
<BookOpen className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
className="text-center p-12 rounded-lg border"
|
||||||
<p className="text-lg text-gray-700 font-medium">No entries found</p>
|
style={{ background: "var(--surface)", borderColor: "var(--border)" }}
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
>
|
||||||
|
<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
|
Try adjusting your filters or create a new entry
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
// Mock the knowledge API
|
||||||
vi.mock("@/lib/api/knowledge");
|
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
|
// Mock Next.js router
|
||||||
const mockPush = vi.fn();
|
const mockPush = vi.fn();
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { fetchKnowledgeGraph } from "@/lib/api/knowledge";
|
import { fetchKnowledgeGraph } from "@/lib/api/knowledge";
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
import ELK from "elkjs/lib/elk.bundled.js";
|
import ELK from "elkjs/lib/elk.bundled.js";
|
||||||
|
|
||||||
// PDA-friendly status colors from CLAUDE.md
|
// PDA-friendly status colors from CLAUDE.md
|
||||||
@@ -376,10 +377,7 @@ export function KnowledgeGraphViewer({
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen">
|
<div className="flex items-center justify-center h-screen">
|
||||||
<div
|
<MosaicSpinner size={48} label="Loading knowledge graph..." />
|
||||||
data-testid="loading-spinner"
|
|
||||||
className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -387,11 +385,14 @@ export function KnowledgeGraphViewer({
|
|||||||
if (error || !graphData) {
|
if (error || !graphData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-screen p-8">
|
<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>
|
<div className="text-sm text-gray-500 dark:text-gray-400">{error}</div>
|
||||||
<button
|
<button
|
||||||
onClick={loadGraph}
|
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
|
Retry
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchKnowledgeStats } from "@/lib/api/knowledge";
|
import { fetchKnowledgeStats } from "@/lib/api/knowledge";
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
interface KnowledgeStats {
|
interface KnowledgeStats {
|
||||||
@@ -61,13 +62,20 @@ export function StatsDashboard(): React.JSX.Element {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-12">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !stats) {
|
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;
|
const { overview, mostConnected, recentActivity, tagDistribution } = stats;
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { EntryEditor } from "../EntryEditor";
|
import { EntryEditor } from "../EntryEditor";
|
||||||
|
|
||||||
// Mock the LinkAutocomplete component
|
// Mock KnowledgeEditor since Tiptap requires a full DOM
|
||||||
vi.mock("../LinkAutocomplete", () => ({
|
vi.mock("../KnowledgeEditor", () => ({
|
||||||
LinkAutocomplete: (): React.JSX.Element => (
|
KnowledgeEditor: ({
|
||||||
<div data-testid="link-autocomplete">LinkAutocomplete</div>
|
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();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render textarea in edit mode by default", (): void => {
|
it("should render KnowledgeEditor component", (): void => {
|
||||||
render(<EntryEditor {...defaultProps} />);
|
render(<EntryEditor {...defaultProps} />);
|
||||||
|
|
||||||
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
expect(screen.getByTestId("knowledge-editor")).toBeInTheDocument();
|
||||||
expect(textarea).toBeInTheDocument();
|
|
||||||
expect(textarea.tagName).toBe("TEXTAREA");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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.";
|
const content = "# Test Content\n\nThis is a test.";
|
||||||
render(<EntryEditor {...defaultProps} content={content} />);
|
render(<EntryEditor {...defaultProps} content={content} />);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
const editor = screen.getByTestId("knowledge-editor");
|
||||||
const textarea = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
|
expect(editor).toHaveAttribute("data-content", content);
|
||||||
expect(textarea.value).toBe(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 user = userEvent.setup();
|
||||||
const onChangeMock = vi.fn();
|
const onChangeMock = vi.fn();
|
||||||
|
|
||||||
render(<EntryEditor {...defaultProps} onChange={onChangeMock} />);
|
render(<EntryEditor {...defaultProps} onChange={onChangeMock} />);
|
||||||
|
|
||||||
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
await user.click(screen.getByTestId("trigger-change"));
|
||||||
await user.type(textarea, "Hello");
|
expect(onChangeMock).toHaveBeenCalledWith("updated content");
|
||||||
|
|
||||||
expect(onChangeMock).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should toggle between edit and preview modes", async (): Promise<void> => {
|
it("should render with entry-editor wrapper class", (): void => {
|
||||||
const user = userEvent.setup();
|
const { container } = render(<EntryEditor {...defaultProps} />);
|
||||||
const content = "# Test\n\nPreview this content.";
|
|
||||||
|
|
||||||
render(<EntryEditor {...defaultProps} content={content} />);
|
expect(container.querySelector(".entry-editor")).toBeInTheDocument();
|
||||||
|
|
||||||
// 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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -356,7 +356,7 @@ function NavItem({ item, isActive, collapsed }: NavItemProps): React.JSX.Element
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "11px",
|
gap: "11px",
|
||||||
padding: "9px 10px",
|
padding: "9px 10px",
|
||||||
borderRadius: "6px",
|
borderRadius: "var(--r-sm)",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: isActive ? "var(--text)" : "var(--muted)",
|
color: isActive ? "var(--text)" : "var(--muted)",
|
||||||
|
|||||||
@@ -37,16 +37,31 @@ export function BaseWidget({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-widget-id={id}
|
data-widget-id={id}
|
||||||
className={cn(
|
className={cn("flex flex-col h-full overflow-hidden", className)}
|
||||||
"flex flex-col h-full bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden",
|
style={{
|
||||||
className
|
background: "var(--surface)",
|
||||||
)}
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-sm)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Widget Header */}
|
{/* 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">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
|
<h3 className="text-sm font-semibold truncate" style={{ color: "var(--text)" }}>
|
||||||
{description && <p className="text-xs text-gray-500 truncate mt-0.5">{description}</p>}
|
{title}
|
||||||
|
</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs truncate mt-0.5" style={{ color: "var(--muted)" }}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Control buttons - only show if handlers provided */}
|
{/* Control buttons - only show if handlers provided */}
|
||||||
@@ -56,7 +71,8 @@ export function BaseWidget({
|
|||||||
<button
|
<button
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
aria-label="Edit widget"
|
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"
|
title="Edit widget"
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
@@ -66,7 +82,8 @@ export function BaseWidget({
|
|||||||
<button
|
<button
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
aria-label="Remove widget"
|
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"
|
title="Remove widget"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -81,15 +98,24 @@ export function BaseWidget({
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<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" />
|
<div
|
||||||
<span className="text-sm text-gray-500">Loading...</span>
|
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>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-red-500 text-sm font-medium mb-1">Error</div>
|
<div className="text-sm font-medium mb-1" style={{ color: "var(--danger)" }}>
|
||||||
<div className="text-xs text-gray-600">{error}</div>
|
Error
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: "var(--muted)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
</div>
|
</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 */
|
/* 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 GridLayout from "react-grid-layout";
|
||||||
import type { Layout, LayoutItem } from "react-grid-layout";
|
import type { Layout, LayoutItem } from "react-grid-layout";
|
||||||
import type { WidgetPlacement } from "@mosaic/shared";
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
@@ -22,6 +22,7 @@ export interface WidgetGridProps {
|
|||||||
layout: WidgetPlacement[];
|
layout: WidgetPlacement[];
|
||||||
onLayoutChange: (layout: WidgetPlacement[]) => void;
|
onLayoutChange: (layout: WidgetPlacement[]) => void;
|
||||||
onRemoveWidget?: (widgetId: string) => void;
|
onRemoveWidget?: (widgetId: string) => void;
|
||||||
|
onEditWidget?: (widgetId: string) => void;
|
||||||
isEditing?: boolean;
|
isEditing?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -30,9 +31,34 @@ export function WidgetGrid({
|
|||||||
layout,
|
layout,
|
||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
onRemoveWidget,
|
onRemoveWidget,
|
||||||
|
onEditWidget,
|
||||||
isEditing = false,
|
isEditing = false,
|
||||||
className,
|
className,
|
||||||
}: WidgetGridProps): React.JSX.Element {
|
}: 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
|
// Convert WidgetPlacement to react-grid-layout Layout format
|
||||||
const gridLayout: Layout = useMemo(
|
const gridLayout: Layout = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -96,22 +122,34 @@ export function WidgetGrid({
|
|||||||
// Empty state
|
// Empty state
|
||||||
if (layout.length === 0) {
|
if (layout.length === 0) {
|
||||||
return (
|
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">
|
<div className="text-center">
|
||||||
<p className="text-gray-500 text-lg font-medium">No widgets yet</p>
|
<p className="text-lg font-medium" style={{ color: "var(--muted)" }}>
|
||||||
<p className="text-gray-400 text-sm mt-1">Add widgets to customize your dashboard</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("widget-grid-container", className)}>
|
<div ref={containerRef} className={cn("widget-grid-container", className)}>
|
||||||
<GridLayout
|
<GridLayout
|
||||||
className="layout"
|
className="layout"
|
||||||
layout={gridLayout}
|
layout={gridLayout}
|
||||||
onLayoutChange={handleLayoutChange}
|
onLayoutChange={handleLayoutChange}
|
||||||
width={1200}
|
width={containerWidth}
|
||||||
gridConfig={{
|
gridConfig={{
|
||||||
cols: 12,
|
cols: 12,
|
||||||
rowHeight: 100,
|
rowHeight: 100,
|
||||||
@@ -147,6 +185,12 @@ export function WidgetGrid({
|
|||||||
id={item.i}
|
id={item.i}
|
||||||
title={widgetDef.displayName}
|
title={widgetDef.displayName}
|
||||||
description={widgetDef.description}
|
description={widgetDef.description}
|
||||||
|
{...(isEditing &&
|
||||||
|
onEditWidget && {
|
||||||
|
onEdit: (): void => {
|
||||||
|
onEditWidget(item.i);
|
||||||
|
},
|
||||||
|
})}
|
||||||
{...(isEditing &&
|
{...(isEditing &&
|
||||||
onRemoveWidget && {
|
onRemoveWidget && {
|
||||||
onRemove: (): void => {
|
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!
|
* 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 { render, screen } from "@testing-library/react";
|
||||||
import { WidgetGrid } from "../WidgetGrid";
|
import { WidgetGrid } from "../WidgetGrid";
|
||||||
import type { WidgetPlacement } from "@mosaic/shared";
|
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
|
// Mock react-grid-layout
|
||||||
vi.mock("react-grid-layout", () => ({
|
vi.mock("react-grid-layout", () => ({
|
||||||
default: ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
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 },
|
||||||
|
];
|
||||||
74
apps/web/src/lib/api/dashboard.ts
Normal file
74
apps/web/src/lib/api/dashboard.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard API Client
|
||||||
|
* Handles dashboard summary data fetching
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiGet } from "./client";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Type definitions matching backend DashboardSummaryDto */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export interface DashboardMetrics {
|
||||||
|
activeAgents: number;
|
||||||
|
tasksCompleted: number;
|
||||||
|
totalTasks: number;
|
||||||
|
tasksInProgress: number;
|
||||||
|
activeProjects: number;
|
||||||
|
errorRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentActivity {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
details: Record<string, unknown> | null;
|
||||||
|
userId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveJobStep {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
phase: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveJob {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
progressPercent: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
steps: ActiveJobStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenBudgetEntry {
|
||||||
|
model: string;
|
||||||
|
used: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummaryResponse {
|
||||||
|
metrics: DashboardMetrics;
|
||||||
|
recentActivity: RecentActivity[];
|
||||||
|
activeJobs: ActiveJob[];
|
||||||
|
tokenBudget: TokenBudgetEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* API function */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch dashboard summary data for the given workspace.
|
||||||
|
*
|
||||||
|
* @param workspaceId - Optional workspace ID sent via X-Workspace-Id header
|
||||||
|
*/
|
||||||
|
export async function fetchDashboardSummary(
|
||||||
|
workspaceId?: string
|
||||||
|
): Promise<DashboardSummaryResponse> {
|
||||||
|
return apiGet<DashboardSummaryResponse>("/api/dashboard/summary", workspaceId ?? undefined);
|
||||||
|
}
|
||||||
@@ -7,84 +7,51 @@ import type { Event } from "@mosaic/shared";
|
|||||||
import { apiGet, type ApiResponse } from "./client";
|
import { apiGet, type ApiResponse } from "./client";
|
||||||
|
|
||||||
export interface EventFilters {
|
export interface EventFilters {
|
||||||
startDate?: Date;
|
/** Filter events starting from this date (inclusive) */
|
||||||
endDate?: Date;
|
startFrom?: Date;
|
||||||
workspaceId?: string;
|
/** 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
|
* 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();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (filters?.startDate) {
|
if (filters?.startFrom) {
|
||||||
params.append("startDate", filters.startDate.toISOString());
|
params.append("startFrom", filters.startFrom.toISOString());
|
||||||
}
|
}
|
||||||
if (filters?.endDate) {
|
if (filters?.startTo) {
|
||||||
params.append("endDate", filters.endDate.toISOString());
|
params.append("startTo", filters.startTo.toISOString());
|
||||||
}
|
}
|
||||||
if (filters?.workspaceId) {
|
if (filters?.projectId) {
|
||||||
params.append("workspaceId", filters.workspaceId);
|
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 queryString = params.toString();
|
||||||
const endpoint = queryString ? `/api/events?${queryString}` : "/api/events";
|
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;
|
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"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ export * from "./domains";
|
|||||||
export * from "./teams";
|
export * from "./teams";
|
||||||
export * from "./personalities";
|
export * from "./personalities";
|
||||||
export * from "./telemetry";
|
export * from "./telemetry";
|
||||||
|
export * from "./dashboard";
|
||||||
|
export * from "./projects";
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import type {
|
|||||||
KnowledgeTag,
|
KnowledgeTag,
|
||||||
KnowledgeEntryVersionWithAuthor,
|
KnowledgeEntryVersionWithAuthor,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
|
EntryStatus,
|
||||||
|
Visibility,
|
||||||
} from "@mosaic/shared";
|
} from "@mosaic/shared";
|
||||||
import { EntryStatus, Visibility } from "@mosaic/shared";
|
|
||||||
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
|
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
|
||||||
|
|
||||||
export interface EntryFilters {
|
export interface EntryFilters {
|
||||||
@@ -370,241 +371,3 @@ export async function fetchKnowledgeGraph(filters?: {
|
|||||||
const endpoint = queryString ? `/api/knowledge/graph?${queryString}` : "/api/knowledge/graph";
|
const endpoint = queryString ? `/api/knowledge/graph?${queryString}` : "/api/knowledge/graph";
|
||||||
return apiGet(endpoint);
|
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 type { Task } from "@mosaic/shared";
|
||||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
import type { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||||
import { apiGet, type ApiResponse } from "./client";
|
import { apiGet, apiPatch, type ApiResponse } from "./client";
|
||||||
|
|
||||||
export interface TaskFilters {
|
export interface TaskFilters {
|
||||||
status?: TaskStatus;
|
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[] = [
|
export async function updateTask(
|
||||||
{
|
id: string,
|
||||||
id: "task-1",
|
data: Partial<Task>,
|
||||||
title: "Review pull request",
|
workspaceId?: string
|
||||||
description: "Review and provide feedback on frontend PR",
|
): Promise<Task> {
|
||||||
status: TaskStatus.IN_PROGRESS,
|
const res = await apiPatch<ApiResponse<Task>>(`/api/tasks/${id}`, data, workspaceId);
|
||||||
priority: TaskPriority.HIGH,
|
return res.data;
|
||||||
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"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { render, screen, act } from "@testing-library/react";
|
|||||||
import { ThemeProvider, useTheme } from "./ThemeProvider";
|
import { ThemeProvider, useTheme } from "./ThemeProvider";
|
||||||
|
|
||||||
function ThemeConsumer(): React.JSX.Element {
|
function ThemeConsumer(): React.JSX.Element {
|
||||||
const { theme, resolvedTheme, setTheme, toggleTheme } = useTheme();
|
const { theme, themeId, themeDefinition, resolvedTheme, setTheme, toggleTheme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span data-testid="theme">{theme}</span>
|
<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>
|
<span data-testid="resolved">{resolvedTheme}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -22,6 +24,27 @@ function ThemeConsumer(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
Set Dark
|
Set Dark
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTheme("nord");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set Nord
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTheme("dracula");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set Dracula
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTheme("system");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set System
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
toggleTheme();
|
toggleTheme();
|
||||||
@@ -38,7 +61,9 @@ describe("ThemeProvider", (): void => {
|
|||||||
|
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
localStorage.clear();
|
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({
|
mockMatchMedia = vi.fn().mockReturnValue({
|
||||||
matches: false,
|
matches: false,
|
||||||
@@ -65,6 +90,7 @@ describe("ThemeProvider", (): void => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("theme")).toHaveTextContent("light");
|
expect(screen.getByTestId("theme")).toHaveTextContent("light");
|
||||||
|
expect(screen.getByTestId("themeId")).toHaveTextContent("light");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should NOT read from old 'jarvis-theme' storage key", (): void => {
|
it("should NOT read from old 'jarvis-theme' storage key", (): void => {
|
||||||
@@ -76,7 +102,6 @@ describe("ThemeProvider", (): void => {
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should default to system, not read from jarvis-theme
|
|
||||||
expect(screen.getByTestId("theme")).toHaveTextContent("system");
|
expect(screen.getByTestId("theme")).toHaveTextContent("system");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,7 +131,6 @@ describe("ThemeProvider", (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw when useTheme is used outside provider", (): void => {
|
it("should throw when useTheme is used outside provider", (): void => {
|
||||||
// Suppress console.error for expected error
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||||
// Intentionally empty
|
// Intentionally empty
|
||||||
});
|
});
|
||||||
@@ -117,4 +141,201 @@ describe("ThemeProvider", (): void => {
|
|||||||
|
|
||||||
consoleSpy.mockRestore();
|
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";
|
"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 {
|
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";
|
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;
|
toggleTheme: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,105 +37,112 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|||||||
|
|
||||||
const STORAGE_KEY = "mosaic-theme";
|
const STORAGE_KEY = "mosaic-theme";
|
||||||
|
|
||||||
function getSystemTheme(): "light" | "dark" {
|
function getSystemThemeId(): "light" | "dark" {
|
||||||
if (typeof window === "undefined") return "dark";
|
if (typeof window === "undefined") return "dark";
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStoredTheme(): Theme {
|
function getStoredPreference(): string {
|
||||||
if (typeof window === "undefined") return "system";
|
if (typeof window === "undefined") return "system";
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
if (stored && (stored === "system" || isValidThemeId(stored))) {
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
return "system";
|
return "system";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function resolveThemeId(preference: string): string {
|
||||||
* Apply the resolved theme to the <html> element via data-theme attribute.
|
if (preference === "system") return getSystemThemeId();
|
||||||
* The default (no attribute or data-theme="dark") renders dark — dark is default.
|
return preference;
|
||||||
* Light theme requires data-theme="light".
|
}
|
||||||
*/
|
|
||||||
function applyThemeAttribute(resolved: "light" | "dark"): void {
|
function applyThemeVariables(themeDef: ThemeDefinition): void {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
if (resolved === "light") {
|
const vars = themeToVariables(themeDef);
|
||||||
root.setAttribute("data-theme", "light");
|
|
||||||
} else {
|
for (const [prop, value] of Object.entries(vars)) {
|
||||||
// Remove the attribute so the default (dark) CSS variables apply.
|
root.style.setProperty(prop, value);
|
||||||
root.removeAttribute("data-theme");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set data-theme attribute for CSS selectors that depend on light/dark
|
||||||
|
root.setAttribute("data-theme", themeDef.isDark ? "dark" : "light");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThemeProviderProps {
|
interface ThemeProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
defaultTheme?: Theme;
|
defaultTheme?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
defaultTheme = "system",
|
defaultTheme = "system",
|
||||||
}: ThemeProviderProps): React.JSX.Element {
|
}: ThemeProviderProps): React.JSX.Element {
|
||||||
const [theme, setThemeState] = useState<Theme>(defaultTheme);
|
const [preference, setPreference] = useState<string>(defaultTheme);
|
||||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("dark");
|
|
||||||
const [mounted, setMounted] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
const storedTheme = getStoredTheme();
|
const stored = getStoredPreference();
|
||||||
const resolved = storedTheme === "system" ? getSystemTheme() : storedTheme;
|
setPreference(stored);
|
||||||
setThemeState(storedTheme);
|
|
||||||
setResolvedTheme(resolved);
|
const id = resolveThemeId(stored);
|
||||||
applyThemeAttribute(resolved);
|
const def = getThemeOrDefault(id);
|
||||||
|
applyThemeVariables(def);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply theme via data-theme attribute on html element
|
// Apply theme whenever preference changes (after mount)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
applyThemeVariables(themeDefinition);
|
||||||
|
}, [themeDefinition, mounted]);
|
||||||
|
|
||||||
const resolved = theme === "system" ? getSystemTheme() : theme;
|
// Listen for system theme changes when preference is "system"
|
||||||
applyThemeAttribute(resolved);
|
|
||||||
setResolvedTheme(resolved);
|
|
||||||
}, [theme, mounted]);
|
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted || theme !== "system") return;
|
if (!mounted || preference !== "system") return;
|
||||||
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const handleChange = (e: MediaQueryListEvent): void => {
|
const handleChange = (e: MediaQueryListEvent): void => {
|
||||||
const resolved = e.matches ? "dark" : "light";
|
const id = e.matches ? "dark" : "light";
|
||||||
setResolvedTheme(resolved);
|
const def = getThemeOrDefault(id);
|
||||||
applyThemeAttribute(resolved);
|
applyThemeVariables(def);
|
||||||
|
// Force re-render by updating preference to trigger useMemo recalc
|
||||||
|
setPreference("system");
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaQuery.addEventListener("change", handleChange);
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
return (): void => {
|
return (): void => {
|
||||||
mediaQuery.removeEventListener("change", handleChange);
|
mediaQuery.removeEventListener("change", handleChange);
|
||||||
};
|
};
|
||||||
}, [theme, mounted]);
|
}, [preference, mounted]);
|
||||||
|
|
||||||
const setTheme = useCallback((newTheme: Theme) => {
|
const setTheme = useCallback((newPreference: string) => {
|
||||||
setThemeState(newTheme);
|
setPreference(newPreference);
|
||||||
localStorage.setItem(STORAGE_KEY, newTheme);
|
localStorage.setItem(STORAGE_KEY, newPreference);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
const toggleTheme = useCallback(() => {
|
||||||
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
||||||
}, [resolvedTheme, setTheme]);
|
}, [resolvedTheme, setTheme]);
|
||||||
|
|
||||||
// Prevent flash by not rendering until mounted
|
// SSR placeholder — render children but with dark defaults
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider
|
<ThemeContext.Provider
|
||||||
value={{
|
value={{
|
||||||
theme: defaultTheme,
|
theme: defaultTheme,
|
||||||
|
themeId: "dark",
|
||||||
|
themeDefinition: darkTheme,
|
||||||
resolvedTheme: "dark",
|
resolvedTheme: "dark",
|
||||||
setTheme: (): void => {
|
setTheme: (): void => {
|
||||||
// No-op during SSR
|
/* no-op during SSR */
|
||||||
},
|
},
|
||||||
toggleTheme: (): void => {
|
toggleTheme: (): void => {
|
||||||
// No-op during SSR
|
/* no-op during SSR */
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -123,7 +152,16 @@ export function ThemeProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggleTheme }}>
|
<ThemeContext.Provider
|
||||||
|
value={{
|
||||||
|
theme: preference,
|
||||||
|
themeId,
|
||||||
|
themeDefinition,
|
||||||
|
resolvedTheme,
|
||||||
|
setTheme,
|
||||||
|
toggleTheme,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</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;
|
||||||
|
}
|
||||||
62
docs/MISSION-MANIFEST.md
Normal file
62
docs/MISSION-MANIFEST.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Mission Manifest — MS18 Theme & Widget System
|
||||||
|
|
||||||
|
> Persistent document tracking full mission scope, status, and session history.
|
||||||
|
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
**ID:** ms18-theme-widgets-20260223
|
||||||
|
**Statement:** Implement MS18 (Theme & Widget System) — multi-theme package system, customizable widget dashboard, WYSIWYG knowledge editor, and enhanced Kanban filtering
|
||||||
|
**Phase:** Planning
|
||||||
|
**Current Milestone:** MS18-ThemeWidgets
|
||||||
|
**Progress:** 0 / 1 milestones
|
||||||
|
**Status:** active
|
||||||
|
**Last Updated:** 2026-02-23T13:30Z
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
1. Theme system supports 5+ themes (dark, light, + 3 additional built-in)
|
||||||
|
2. Themes are defined as TypeScript packages with CSS variable overrides
|
||||||
|
3. Theme selection UI in Settings with live preview swatches
|
||||||
|
4. UserPreference.theme persists selected theme across sessions
|
||||||
|
5. Dashboard uses customizable WidgetGrid (drag, resize, add, remove widgets)
|
||||||
|
6. Widget picker UI allows browsing and adding widgets from registry
|
||||||
|
7. Per-widget configuration dialog (data source, filters, colors)
|
||||||
|
8. Layout save/load/rename via UserLayout API
|
||||||
|
9. WYSIWYG editor (Tiptap) for knowledge entries with toolbar
|
||||||
|
10. Markdown ↔ rich text round-trip (import/export)
|
||||||
|
11. Kanban board supports project-level and user-level filtering
|
||||||
|
12. Kanban filter bar: project, assignee, priority, search
|
||||||
|
13. All features support all themes (dark/light + new themes)
|
||||||
|
14. Lint, typecheck, and tests pass
|
||||||
|
15. Deployed and smoke-tested at mosaic.woltje.com
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||||
|
| --- | ---- | --------------------- | ----------- | ------------------------- | ------------------------ | ------- | --------- |
|
||||||
|
| 1 | MS18 | Theme & Widget System | not-started | per-task feature branches | #487,#488,#489,#490,#491 | — | — |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
| Target | URL | Method |
|
||||||
|
| ------- | ----------------- | -------------- |
|
||||||
|
| Coolify | mosaic.woltje.com | CI/CD pipeline |
|
||||||
|
|
||||||
|
## Token Budget
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
| ------ | ----------------- |
|
||||||
|
| Budget | ~500K (estimated) |
|
||||||
|
| Used | 0 |
|
||||||
|
| Mode | normal |
|
||||||
|
|
||||||
|
## Session History
|
||||||
|
|
||||||
|
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||||
|
| ------- | --------------- | ----------------- | -------- | ------------ | ------------------- |
|
||||||
|
| S1 | Claude Opus 4.6 | 2026-02-23T13:30Z | — | — | Planning (PLAN-001) |
|
||||||
|
|
||||||
|
## Scratchpad
|
||||||
|
|
||||||
|
Path: `docs/scratchpads/ms18-theme-widgets-20260223.md`
|
||||||
340
docs/PRD.md
340
docs/PRD.md
@@ -24,43 +24,90 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
|||||||
9. Build global terminal, project chat, and master chat session
|
9. Build global terminal, project chat, and master chat session
|
||||||
10. Configure telemetry with opt-out support
|
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 Coolify at 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
|
||||||
|
|
||||||
## Scope
|
## 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)
|
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.
|
||||||
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
|
1. Projects list page with CRUD (wire to existing `/api/projects`)
|
||||||
4. Collapsible sidebar with nav groups, icons, badges, active states, collapse/expand button
|
2. Project workspace/detail page (wire to `/api/projects/:id`, `/api/tasks`, `/api/runner-jobs`)
|
||||||
5. Responsive layout with hamburger button at small breakpoints, sidebar hidden by default at mobile
|
3. Kanban board page with status-based columns (wire to existing `/api/tasks`)
|
||||||
6. Light/dark theme matching the reference design
|
4. File Manager page with tree/list view and CRUD (wire to existing `/api/knowledge`)
|
||||||
7. Mosaic logo icon as global loading spinner
|
5. Logs & Telemetry page with log viewer and filtering (wire to `/api/runner-jobs`, job steps, events)
|
||||||
8. Shared component updates in packages/ui (Card, Badge, Button, Dot, MetricsStrip, ProgressBar, FilterTabs, SectionHeader, Table, LogLine, Terminal panel)
|
6. Settings root/index page linking to existing subpages
|
||||||
9. Dashboard page: metrics strip, active orchestrator sessions, quick actions, activity feed, token budget
|
7. Custom 404 page for unknown routes
|
||||||
10. Grain overlay texture from reference design
|
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)
|
### In Scope (Future Milestones — Documented for Planning)
|
||||||
|
|
||||||
11. Additional pages: Projects, Workspace, Kanban, File Manager, Logs & Telemetry, Settings, Profile
|
11. Theme system with installable theme packages (MS18)
|
||||||
12. Theme system with installable theme packages
|
12. Widget system with installable widget packages, customizable sizes (MS18)
|
||||||
13. Widget system with installable widget packages, customizable sizes
|
13. Global terminal: project/orchestrator level, smart (MS19)
|
||||||
14. Global terminal (project/orchestrator level, smart)
|
14. Project-level orchestrator chat (MS19)
|
||||||
15. Project-level orchestrator chat
|
15. Master chat session: collapsible sidebar/slideout, always available (MS19)
|
||||||
16. Master chat session (collapsible sidebar/slideout, always available)
|
16. Settings page for ALL environment variables, dynamically configurable via webUI (MS20)
|
||||||
17. Settings page for ALL environment variables, dynamically configurable via webUI
|
17. Multi-tenant configuration with admin user management (MS20)
|
||||||
18. Multi-tenant configuration with admin user management
|
18. Team management with shared data spaces and chat rooms (MS20)
|
||||||
19. Team management with shared data spaces and chat rooms
|
19. RBAC for file access, resources, models (MS20)
|
||||||
20. RBAC for file access, resources, models
|
20. Federation: master-master and master-slave with key exchange (MS21)
|
||||||
21. Federation: master-master and master-slave with key exchange
|
21. Federation testing: 3 instances on Coolify (woltje.com domain) (MS21)
|
||||||
22. Federation testing: 3 instances on Coolify (woltje.com domain)
|
22. Agent task mapping configuration: system-level defaults, user-level overrides (MS22)
|
||||||
23. Agent task mapping configuration (system-level defaults, user-level overrides)
|
23. Telemetry: opt-out, customizable endpoint, sanitized data (MS22)
|
||||||
24. Telemetry: opt-out, customizable endpoint, sanitized data
|
24. File manager with WYSIWYG editing: system/user/project levels (MS18)
|
||||||
25. File manager with WYSIWYG editing (system/user/project levels)
|
25. User-level and project-level Kanban with filtering (MS18)
|
||||||
26. User-level and project-level Kanban with filtering
|
26. Break-glass authentication user (MS20)
|
||||||
27. Break-glass authentication user
|
27. Playwright E2E tests for all pages (MS23)
|
||||||
28. Playwright E2E tests for all pages
|
28. API documentation via Swagger (MS23)
|
||||||
29. API documentation via Swagger
|
29. Backend endpoints for all dashboard data (MS17 — already complete for existing modules)
|
||||||
30. Backend endpoints for all dashboard data
|
30. Profile page linked from user card (MS16)
|
||||||
|
|
||||||
### Out of Scope
|
### Out of Scope
|
||||||
|
|
||||||
@@ -74,7 +121,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
|
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
|
2. A standard `jarvis-user` must operate at a lower permission level
|
||||||
3. A break-glass user must have access without Authentik authentication
|
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
|
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
|
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
|
7. Hamburger button visible at lower breakpoints for sidebar control
|
||||||
@@ -89,12 +136,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"]`
|
- Dark theme as default (`:root`), light theme via `[data-theme="light"]`
|
||||||
- Fonts: Outfit (body), Fira Code (monospace)
|
- Fonts: Outfit (body), Fira Code (monospace)
|
||||||
- All components must use design tokens, never hardcoded colors
|
- All components must use design tokens, never hardcoded colors
|
||||||
|
- **Status: COMPLETE (MS15)**
|
||||||
|
|
||||||
### FR-002: App Shell Layout
|
### FR-002: App Shell Layout
|
||||||
|
|
||||||
- CSS Grid: sidebar column + header row + main content
|
- CSS Grid: sidebar column + header row + main content
|
||||||
- Full-width header spanning above sidebar and 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."
|
- 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
|
### FR-003: Sidebar Navigation
|
||||||
|
|
||||||
@@ -103,6 +152,7 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
|||||||
- Active state indicator (left border accent)
|
- Active state indicator (left border accent)
|
||||||
- User card in footer with avatar, name, role, online status
|
- User card in footer with avatar, name, role, online status
|
||||||
- ASSUMPTION: Sidebar footer user card navigates to Profile page. Rationale: Matches reference design behavior.
|
- 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
|
### FR-004: Header/Topbar
|
||||||
|
|
||||||
@@ -113,6 +163,7 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
|||||||
- Notification bell with badge
|
- Notification bell with badge
|
||||||
- Theme toggle (sun/moon icon)
|
- Theme toggle (sun/moon icon)
|
||||||
- User avatar button with dropdown (Profile, Account Settings, Sign Out)
|
- User avatar button with dropdown (Profile, Account Settings, Sign Out)
|
||||||
|
- **Status: COMPLETE (MS15)**
|
||||||
|
|
||||||
### FR-005: Responsive Design
|
### FR-005: Responsive Design
|
||||||
|
|
||||||
@@ -120,6 +171,7 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
|||||||
- Below md: sidebar hidden, hamburger button in header
|
- Below md: sidebar hidden, hamburger button in header
|
||||||
- md-lg: sidebar can be toggled
|
- md-lg: sidebar can be toggled
|
||||||
- lg+: sidebar visible by default
|
- lg+: sidebar visible by default
|
||||||
|
- **Status: COMPLETE (MS15)**
|
||||||
|
|
||||||
### FR-006: Dashboard Page
|
### FR-006: Dashboard Page
|
||||||
|
|
||||||
@@ -128,28 +180,98 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
|||||||
- Quick Actions 2x2 grid
|
- Quick Actions 2x2 grid
|
||||||
- Activity Feed sidebar card
|
- Activity Feed sidebar card
|
||||||
- Token Budget sidebar card with progress bars
|
- Token Budget sidebar card with progress bars
|
||||||
|
- Wired to real API via `/api/dashboard/summary`
|
||||||
|
- **Status: COMPLETE (Go-Live MVP)**
|
||||||
|
|
||||||
### FR-007: Loading Spinner
|
### FR-007: Loading Spinner
|
||||||
|
|
||||||
- Mosaic logo icon (4 corner squares + center circle) with CSS rotation animation
|
- Mosaic logo icon (4 corner squares + center circle) with CSS rotation animation
|
||||||
- Used as global loading indicator across all pages
|
- Used as global loading indicator across all pages
|
||||||
- Available as a shared component
|
- Available as a shared component
|
||||||
|
- **Status: COMPLETE (MS15)**
|
||||||
|
|
||||||
### FR-008: Theme System (Future Milestone)
|
### FR-008: Projects Page (MS16)
|
||||||
|
|
||||||
|
- 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: 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 (Future — MS18)
|
||||||
|
|
||||||
- Support multiple themes beyond default dark/light
|
- Support multiple themes beyond default dark/light
|
||||||
- Themes are installable packages from Mosaic Stack repo
|
- Themes are installable packages from Mosaic Stack repo
|
||||||
- Theme installation and selection from Settings page
|
- 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.
|
- ASSUMPTION: Initial implementation supports dark/light from reference design. Multi-theme package system is a future milestone. Rationale: Foundation must be solid before extensibility.
|
||||||
|
|
||||||
### FR-009: Terminal Panel (Future Milestone)
|
### FR-017: Terminal Panel (Future — MS19)
|
||||||
|
|
||||||
- Bottom drawer panel, toggleable from header and sidebar
|
- Bottom drawer panel, toggleable from header and sidebar
|
||||||
- Multiple tabs (Orchestrator, Shell, Build)
|
- Multiple tabs (Orchestrator, Shell, Build)
|
||||||
- Smart terminal operating at project/orchestrator level
|
- Smart terminal operating at project/orchestrator level
|
||||||
- Global terminal for system interaction
|
- Global terminal for system interaction
|
||||||
|
|
||||||
### FR-010: Settings Page (Future Milestone)
|
### FR-018: Settings Configuration (Future — MS20)
|
||||||
|
|
||||||
- All environment variables configurable via UI
|
- All environment variables configurable via UI
|
||||||
- Minimal launch env vars, rest configurable dynamically
|
- Minimal launch env vars, rest configurable dynamically
|
||||||
@@ -165,30 +287,54 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Milestone 0.0.15
|
### MS15-DashboardShell — COMPLETE
|
||||||
|
|
||||||
1. Design tokens from dashboard.html are implemented in globals.css
|
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
|
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
|
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
|
4. ~~Hamburger button appears at mobile breakpoints, sidebar hidden by default~~ DONE
|
||||||
5. Light/dark theme toggle works across all components
|
5. ~~Light/dark theme toggle works across all components~~ DONE
|
||||||
6. Mosaic logo spinner is used as site-wide loading indicator
|
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
|
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)
|
8. ~~All shared components in packages/ui use design tokens (no hardcoded colors)~~ DONE
|
||||||
9. Lint, typecheck, and existing tests pass
|
9. ~~Lint, typecheck, and existing tests pass~~ DONE
|
||||||
10. Grain overlay texture from reference is applied
|
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
|
||||||
|
|
||||||
### Full Project (All Milestones)
|
### Full Project (All Milestones)
|
||||||
|
|
||||||
11. jarvis user logs in via Authentik, has admin access to all pages
|
29. jarvis user logs in via Authentik, has admin access to all pages
|
||||||
12. jarvis-user has standard access at lower permission level
|
30. jarvis-user has standard access at lower permission level
|
||||||
13. Break-glass user has access without Authentik
|
31. Break-glass user has access without Authentik
|
||||||
14. Three Mosaic Stack instances on Coolify with federation testing
|
32. Three Mosaic Stack instances on Coolify with federation testing
|
||||||
15. Playwright tests confirm all pages, functions, theming work
|
33. Playwright tests confirm all pages, functions, theming work
|
||||||
16. No errors during site navigation
|
34. No errors during site navigation
|
||||||
17. API documented via Swagger with proper auth gating
|
35. API documented via Swagger with proper auth gating
|
||||||
18. Telemetry working locally with wide-event logging
|
36. Telemetry working locally with wide-event logging
|
||||||
19. Mosaic Telemetry properly reporting to telemetry endpoint
|
37. Mosaic Telemetry properly reporting to telemetry endpoint
|
||||||
|
|
||||||
## Constraints and Dependencies
|
## Constraints and Dependencies
|
||||||
|
|
||||||
@@ -199,35 +345,75 @@ The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard
|
|||||||
5. PostgreSQL 17 with Prisma — all settings stored in DB
|
5. PostgreSQL 17 with Prisma — all settings stored in DB
|
||||||
6. Coolify for deployment — 3 instances needed for federation testing
|
6. Coolify for deployment — 3 instances needed for federation testing
|
||||||
7. packages/ui is shared across apps — changes affect all consumers
|
7. packages/ui is shared across apps — changes affect all consumers
|
||||||
|
8. Backend API modules already exist for all page data needs — no new API endpoints required for MS16+MS17 scope
|
||||||
|
|
||||||
## Risks and Open Questions
|
## Risks and Open Questions
|
||||||
|
|
||||||
1. **Risk**: Changing globals.css design tokens may break existing pages (login, knowledge, calendar). Mitigation: Thorough regression testing.
|
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**: packages/ui uses hardcoded Tailwind colors — migration to CSS variables needs care. Mitigation: Phase the migration, test each component.
|
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. **Open**: Exact federation protocol details for master-master vs master-slave data sync.
|
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**: Specific telemetry data points to collect.
|
4. ~~**Open**: Exact task status values for Kanban columns~~ **RESOLVED** — TaskStatus enum: NOT_STARTED, IN_PROGRESS, PAUSED, COMPLETED, ARCHIVED (5 columns).
|
||||||
5. **Open**: Agent task mapping configuration schema (informed by OpenClaw research).
|
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 |
|
||||||
|
| Telemetry | Internal | Logging/monitoring |
|
||||||
|
|
||||||
## Testing and Verification
|
## Testing and Verification
|
||||||
|
|
||||||
1. Baseline: `pnpm lint && pnpm build` must pass
|
1. Baseline: `pnpm lint && pnpm build` must pass
|
||||||
2. Situational: Visual verification at sm/md/lg/xl breakpoints
|
2. Situational: All sidebar links navigate without 404
|
||||||
3. Situational: Theme toggle across all pages
|
3. Situational: Each new page renders with real API data
|
||||||
4. Situational: Sidebar collapse/expand at all breakpoints
|
4. Situational: Theme toggle on each new page
|
||||||
5. E2E: Playwright tests for all page navigation
|
5. Situational: Responsive verification at sm/md/lg/xl
|
||||||
6. E2E: Auth flow with Authentik
|
6. E2E: Playwright tests for all page navigation (MS23)
|
||||||
7. Federation: Master-master and master-slave data access tests
|
7. E2E: Auth flow with Authentik (MS23)
|
||||||
|
8. Federation: Master-master and master-slave data access tests (MS21)
|
||||||
|
|
||||||
## Delivery/Milestone Intent
|
## Delivery/Milestone Intent
|
||||||
|
|
||||||
| Milestone | Version | Focus |
|
| Milestone | Version | Focus | Status |
|
||||||
| ----------------------- | ------- | ----------------------------------------------------------------- |
|
| ------------------------------ | ------- | ----------------------------------------------------------------- | ----------- |
|
||||||
| MS15-DashboardShell | 0.0.15 | Design system + app shell + dashboard page |
|
| MS15-DashboardShell | 0.0.15 | Design system + app shell + dashboard page | COMPLETE |
|
||||||
| MS16-Pages | 0.0.16 | Projects, Workspace, Kanban, Settings, Profile, Files, Logs pages |
|
| Go-Live MVP | 0.1.0 | Dashboard polish, ingestion, agent visibility, deploy | COMPLETE |
|
||||||
| MS17-BackendIntegration | 0.0.17 | API endpoints, real data, Swagger docs |
|
| MS16+MS17-PagesDataIntegration | 0.1.1 | All pages built + wired to real API data | COMPLETE |
|
||||||
| MS18-ThemeWidgets | 0.0.18 | Theme package system, widget registry, dashboard customization |
|
| MS18-ThemeWidgets | 0.1.2 | Theme package system, widget registry, dashboard customization | IN PROGRESS |
|
||||||
| MS19-ChatTerminal | 0.0.19 | Global terminal, project chat, master chat session |
|
| MS19-ChatTerminal | 0.1.x | Global terminal, project chat, master chat session | NOT STARTED |
|
||||||
| MS20-MultiTenant | 0.0.20 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth |
|
| MS20-MultiTenant | 0.2.0 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth | NOT STARTED |
|
||||||
| MS21-Federation | 0.0.21 | Federation (M-M, M-S), 3 instances, key exchange, data separation |
|
| MS21-Federation | 0.2.x | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
|
||||||
| MS22-AgentTelemetry | 0.0.22 | Agent task mapping, telemetry, wide-event logging |
|
| MS22-AgentTelemetry | 0.2.x | Agent task mapping, telemetry, wide-event logging | NOT STARTED |
|
||||||
| MS23-Testing | 0.0.23 | Playwright E2E, federation tests, documentation finalization |
|
| 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.
|
||||||
|
|||||||
34
docs/TASKS.md
Normal file
34
docs/TASKS.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Tasks — MS18 Theme & Widget System
|
||||||
|
|
||||||
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
|
||||||
|
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||||
|
| ----------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---- | -------------------------------- | ------------------------------------------------------ | ------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ------------------------------------------ |
|
||||||
|
| TW-PLAN-001 | done | Plan MS18 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | TW-THM-001,TW-WDG-001,TW-EDT-001,TW-KBN-001 | orchestrator | 2026-02-23 | 2026-02-23 | 15K | ~12K | Planning complete, all artifacts committed |
|
||||||
|
| TW-THM-001 | done | Theme architecture — Create theme definition interface, theme registry, and 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TS files | #487 | web | feat/ms18-theme-architecture | TW-PLAN-001 | TW-THM-002,TW-THM-003 | worker | 2026-02-23 | 2026-02-23 | 30K | ~15K | PR #493 merged |
|
||||||
|
| TW-THM-002 | done | ThemeProvider upgrade — Load themes dynamically from registry, apply CSS variables, support instant theme switching without page reload | #487 | web | feat/ms18-theme-provider-upgrade | TW-THM-001 | TW-THM-003,TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~12K | PR #494 merged |
|
||||||
|
| TW-THM-003 | done | Theme selection UI — Settings page section with theme browser, live preview swatches, persist selection to UserPreference.theme via API | #487 | web | feat/ms18-theme-selection-ui | TW-THM-001,TW-THM-002 | TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~10K | PR #495 merged |
|
||||||
|
| TW-WDG-001 | done | Widget definition seeding — Seed 7 existing widgets into widget_definitions table with correct sizing constraints and configSchema | #488 | api | feat/ms18-widget-seed | TW-PLAN-001 | TW-WDG-002 | worker | 2026-02-23 | 2026-02-23 | 15K | ~8K | PR #496 merged |
|
||||||
|
| TW-WDG-002 | done | Dashboard → WidgetGrid migration — Replace hardcoded dashboard layout with WidgetGrid, load/save layout via UserLayout API, default layout on first visit | #488 | web | feat/ms18-widget-grid-migration | TW-WDG-001 | TW-WDG-003,TW-WDG-004,TW-WDG-005 | worker | 2026-02-23 | 2026-02-23 | 40K | ~20K | PR #497 merged |
|
||||||
|
| TW-WDG-003 | done | Widget picker UI — Drawer/dialog to browse available widgets from registry, preview size/description, add to dashboard | #488 | web | feat/ms18-widget-picker | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 25K | ~12K | PR #498 merged |
|
||||||
|
| TW-WDG-004 | done | Widget configuration UI — Per-widget settings dialog using configSchema, configure data source/filters/colors/title | #488 | web | feat/ms18-layout-management | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~8K | PR #499 merged (bundled with WDG-005) |
|
||||||
|
| TW-WDG-005 | done | Layout management UI — Save/rename/switch/delete layouts, reset to default. UI controls in dashboard header area | #488 | web | feat/ms18-layout-management | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 20K | ~8K | PR #499 merged (bundled with WDG-004) |
|
||||||
|
| TW-EDT-001 | done | Tiptap integration — Install @tiptap/react + extensions, build KnowledgeEditor component with toolbar (headings, bold, italic, lists, code, links, tables) | #489 | web | feat/ms18-tiptap-editor | TW-PLAN-001 | TW-EDT-002 | worker | 2026-02-23 | 2026-02-23 | 35K | ~12K | PR #500 merged |
|
||||||
|
| TW-EDT-002 | done | Markdown round-trip + File Manager integration — Import markdown to Tiptap, export to markdown + HTML. Replace textarea in knowledge create/edit | #489 | web | feat/ms18-markdown-roundtrip | TW-EDT-001 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~10K | PR #501 merged |
|
||||||
|
| TW-KBN-001 | done | Kanban filtering — Add filter bar (project, assignee, priority, search). Support project-level and user-level views. URL param persistence | #490 | web | feat/ms18-kanban-filtering | TW-PLAN-001 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~10K | PR #502 merged |
|
||||||
|
| TW-VER-001 | done | Tests — Unit tests for new components, update existing tests, fix any regressions | #491 | web | feat/ms18-verification-tests | TW-WDG-003,TW-WDG-004,TW-WDG-005,TW-EDT-002,TW-KBN-001 | TW-VER-002,TW-DOC-001 | worker | 2026-02-23 | 2026-02-23 | 25K | ~8K | PR pending; 20 new tests (1195 total) |
|
||||||
|
| TW-VER-002 | not-started | Theme verification — Verify all 5 themes render correctly on all pages, no broken colors/contrast issues | #491 | web | TBD | TW-THM-003,TW-VER-001 | TW-DOC-001 | worker | — | — | 15K | — | |
|
||||||
|
| TW-DOC-001 | not-started | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #491 | — | — | TW-VER-001,TW-VER-002 | TW-VER-003 | orchestrator | — | — | 10K | — | |
|
||||||
|
| TW-VER-003 | not-started | Deploy to Coolify + smoke test — Deploy, verify themes/widgets/editor/kanban all functional, auth working, no console errors | #491 | — | — | TW-DOC-001 | | orchestrator | — | — | 15K | — | |
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
| ------------- | ---------------------------------------------------------------------- |
|
||||||
|
| Total tasks | 16 |
|
||||||
|
| Completed | 14 (PLAN-001, THM-001–003, WDG-001–005, EDT-001–002, KBN-001, VER-001) |
|
||||||
|
| In Progress | 0 |
|
||||||
|
| Remaining | 2 |
|
||||||
|
| PRs merged | #493–#502 |
|
||||||
|
| Issues closed | #487, #488, #489, #490 |
|
||||||
|
| Milestone | MS18-ThemeWidgets |
|
||||||
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.
|
||||||
35
docs/scratchpads/468-kanban-page.md
Normal file
35
docs/scratchpads/468-kanban-page.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# PG-PAGE-003: Kanban Board Page
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Build Kanban board page with drag-and-drop columns mapped to TaskStatus.
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
|
||||||
|
Refs #468
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- `apps/web/src/app/(authenticated)/kanban/page.tsx` (new)
|
||||||
|
- `apps/web/src/lib/api/tasks.ts` (added `updateTask`)
|
||||||
|
- `package.json` / `pnpm-lock.yaml` (added `@hello-pangea/dnd`)
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
- Used `@hello-pangea/dnd` (maintained fork of react-beautiful-dnd) for drag-and-drop
|
||||||
|
- 5 columns mapped to TaskStatus enum: NOT_STARTED, IN_PROGRESS, PAUSED, COMPLETED, ARCHIVED
|
||||||
|
- Optimistic update pattern: move card immediately, revert by re-fetching on API failure
|
||||||
|
- Priority badge always shown (field is non-optional in Task type)
|
||||||
|
- Column count badge uses `color-mix()` for transparent accent backgrounds
|
||||||
|
- Horizontal scroll on mobile with `overflow-x: auto` and `min-width: 280px` per column
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Lint: clean (my files pass, pre-existing errors in runner-jobs.ts not my scope)
|
||||||
|
- Build: `next build` succeeds, `/kanban` route present in output
|
||||||
|
- TypeScript: no type errors
|
||||||
|
- Design tokens: all colors from CSS custom properties, no hardcoded colors
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Complete
|
||||||
101
docs/scratchpads/mosaic-stack-go-live-mvp-20260222.md
Normal file
101
docs/scratchpads/mosaic-stack-go-live-mvp-20260222.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Mission Scratchpad — Mosaic Stack Go-Live MVP
|
||||||
|
|
||||||
|
> Append-only log. NEVER delete entries. NEVER overwrite sections.
|
||||||
|
> This is the orchestrator's working memory across sessions.
|
||||||
|
|
||||||
|
## Original Mission Prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
Continue Mosaic Stack Go-Live MVP from existing state.
|
||||||
|
- Mission: Ship Mosaic Stack MVP: operational dashboard with theming, task ingestion,
|
||||||
|
one visible agent cycle, deployed and smoke-tested.
|
||||||
|
- 4 milestones: Dashboard Polish+Theming, Task Ingestion Pipeline,
|
||||||
|
Agent Cycle Visibility, Deploy+Smoke Test
|
||||||
|
- Prior work: MS15-DashboardShell complete (PRs #451-454)
|
||||||
|
- Design ref: mosaic-stack-website/docs/designs/round-5/claude/01/dashboard.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Planning Decisions
|
||||||
|
|
||||||
|
### 2026-02-22: Phase-1 Task Breakdown
|
||||||
|
|
||||||
|
Baseline assessment:
|
||||||
|
|
||||||
|
- Lint: PASS, Typecheck: PASS, Tests: FAIL (9 failures)
|
||||||
|
- UI Button.test.tsx: 4 fails (old Tailwind class assertions vs new CSS token Button)
|
||||||
|
- Web page.test.tsx: 5 fails (old widget layout vs Phase 3 rebuild)
|
||||||
|
- Design system + theming already substantially complete from MS15
|
||||||
|
- 5 live widgets all use mock data (real data integration is phase-2)
|
||||||
|
- 4 legacy widgets unused (hardcoded light theme, pre-Phase 3)
|
||||||
|
|
||||||
|
Tasks created: MS-P1-001 through MS-P1-004, issue #457, milestone Go-Live-MVP-Phase1 (0.0.16)
|
||||||
|
Estimated total: ~50K tokens
|
||||||
|
|
||||||
|
## Session Log
|
||||||
|
|
||||||
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
|
| ------- | ---------- | --------- | ---------- | ------------------------------------------------------ |
|
||||||
|
| S1 | 2026-02-22 | phase-1 | 4/4 | COMPLETE — PR #458 merged (07f5225), issue #457 closed |
|
||||||
|
|
||||||
|
### 2026-02-23: Phase-1 Completion Summary
|
||||||
|
|
||||||
|
- PR #458 merged to main (squash), commit 07f5225
|
||||||
|
- Issue #457 closed
|
||||||
|
- 4/4 tasks done, all quality gates green
|
||||||
|
- Pre-existing bug noted: Toast.tsx var(--info) undefined (not in scope)
|
||||||
|
- Net: -373 lines (legacy cleanup + responsive CSS additions)
|
||||||
|
- Review: approve (0 blockers, 0 critical security)
|
||||||
|
|
||||||
|
### 2026-02-23: Phase-2 Completion Summary
|
||||||
|
|
||||||
|
- PR #460 merged to main (squash), commit 7581d26
|
||||||
|
- Issue #459 closed
|
||||||
|
- 3/3 tasks done (MS-P2-001 through MS-P2-003)
|
||||||
|
- New files: dashboard module (controller, service, DTOs, tests), API client, typed widget props
|
||||||
|
- Review blockers fixed: race condition (null workspaceId guard), TypeScript strict typing, error state UI
|
||||||
|
- Net: +1042 lines, -253 lines (18 files changed)
|
||||||
|
- All quality gates green: lint 8/8, typecheck 7/7, test 8/8 (no cache)
|
||||||
|
|
||||||
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
|
| ------- | ---------- | --------- | ---------- | ------------------------------------------------------ |
|
||||||
|
| S2 | 2026-02-23 | phase-2 | 3/3 | COMPLETE — PR #460 merged (7581d26), issue #459 closed |
|
||||||
|
|
||||||
|
### 2026-02-23: Phase-3 Completion Summary
|
||||||
|
|
||||||
|
- PR #462 merged to main (squash), commit 458cac7
|
||||||
|
- Issue #461 closed
|
||||||
|
- 3/3 tasks done (MS-P3-001 through MS-P3-003)
|
||||||
|
- WebSocket emits wired into RunnerJobsService (create/cancel/retry/updateStatus/updateProgress)
|
||||||
|
- Dashboard polling at 30s interval + OrchestratorSessions progress bars + step status labels
|
||||||
|
- Review: approve (0 critical, 2 important fixed: stale-data guard, enum consistency)
|
||||||
|
- All quality gates green: lint 8/8, typecheck 7/7, test 8/8 (no cache)
|
||||||
|
|
||||||
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
|
| ------- | ---------- | --------- | ---------- | ------------------------------------------------------ |
|
||||||
|
| S2 cont | 2026-02-23 | phase-3 | 3/3 | COMPLETE — PR #462 merged (458cac7), issue #461 closed |
|
||||||
|
|
||||||
|
### 2026-02-23: Phase-4 Completion Summary
|
||||||
|
|
||||||
|
- 3/3 tasks done (MS-P4-001 through MS-P4-003)
|
||||||
|
- Built stack-api and stack-web Docker images locally, pushed to git.mosaicstack.dev/mosaic/ registry
|
||||||
|
- Fixed root route redirect: removed app/page.tsx that was intercepting / and redirecting to /tasks
|
||||||
|
- Coolify service stop+start cycle required (restart alone caused flaky proxy routing)
|
||||||
|
- Proxy routing note: Traefik route propagation takes ~5min after service restart; multiple rapid cycles cause intermittent 504s
|
||||||
|
- Smoke test results: web HTTP 200 (0.19s), API health HTTP 200 (0.10s), dashboard API HTTP 401 (auth required)
|
||||||
|
- Dashboard verified in browser: all 6 widgets rendering, auth working (User: Jarvis), dark/light theme toggle
|
||||||
|
- All quality gates green: lint 8/8, typecheck 7/7, test 8/8
|
||||||
|
- Screenshot saved: docs/scratchpads/smoke-test-dashboard.png
|
||||||
|
|
||||||
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
|
| ------- | ---------- | --------- | ---------- | ----------------------- |
|
||||||
|
| S3 | 2026-02-23 | phase-4 | 3/3 | COMPLETE — mission done |
|
||||||
|
|
||||||
|
### Mission Summary
|
||||||
|
|
||||||
|
- 4 milestones, 16 tasks, 4 PRs (#458, #460, #462, + phase-4 PR), 4 issues (#457, #459, #461, #463)
|
||||||
|
- Success criteria met: dashboard at mosaic.woltje.com, task ingestion API, agent cycle visibility, deployed + smoke-tested
|
||||||
|
- Known infra issue: Coolify proxy routing is flaky after rapid restart cycles; needs 5min stabilization
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
## Corrections
|
||||||
84
docs/scratchpads/ms18-theme-widgets-20260223.md
Normal file
84
docs/scratchpads/ms18-theme-widgets-20260223.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Mission Scratchpad — MS18 Theme & Widget System
|
||||||
|
|
||||||
|
> Append-only log. NEVER delete entries. NEVER overwrite sections.
|
||||||
|
> This is the orchestrator's working memory across sessions.
|
||||||
|
|
||||||
|
## Original Mission Prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
Close out MS16+MS17 mission and initialize new mission for MS18 — Theme/Widgets.
|
||||||
|
User confirmed: all 4 feature areas (themes, widgets, WYSIWYG, Kanban filtering).
|
||||||
|
WYSIWYG editor library: agent's choice → Tiptap selected.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Planning Decisions
|
||||||
|
|
||||||
|
### 2026-02-23 — Scope & Architecture
|
||||||
|
|
||||||
|
**Decision: Tiptap for WYSIWYG editor**
|
||||||
|
|
||||||
|
- ProseMirror-based, headless (fits our CSS variable design system perfectly)
|
||||||
|
- Excellent markdown import/export via tiptap-markdown
|
||||||
|
- Battle-tested, TypeScript-first, largest React ecosystem
|
||||||
|
- Alternatives considered: Plate (Slate-based, more opinionated UI, smaller community)
|
||||||
|
|
||||||
|
**Decision: Theme packages as TypeScript files, not DB**
|
||||||
|
|
||||||
|
- Themes defined in `apps/web/src/themes/` as TS files exporting CSS variable maps
|
||||||
|
- Aligns with PRD's "installable packages from Mosaic Stack repo" concept
|
||||||
|
- No new Prisma model needed — `UserPreference.theme` (string field) already exists
|
||||||
|
- Theme files export: name, displayName, description, author, colorPreview, cssVariables
|
||||||
|
- Built-in themes: Dark (default), Light, Nord, Dracula, Solarized Dark
|
||||||
|
- ASSUMPTION: Theme packages are code-level packages (TS files), not runtime-installable npm packages. Rationale: keeps the system simple for MS18; runtime package loading can be added later.
|
||||||
|
|
||||||
|
**Decision: Dashboard migration to WidgetGrid**
|
||||||
|
|
||||||
|
- Current dashboard has hardcoded layout (DashboardMetrics, OrchestratorSessions, QuickActions, ActivityFeed, TokenBudget)
|
||||||
|
- Will migrate to WidgetGrid with these as default widget placements
|
||||||
|
- Need to seed WidgetDefinition records for the 7 registered widgets
|
||||||
|
- Default layout created on first visit if no UserLayout exists
|
||||||
|
- Existing dashboard components become widget implementations
|
||||||
|
|
||||||
|
**Decision: Kanban filtering approach**
|
||||||
|
|
||||||
|
- Add filter bar above columns (project, assignee, priority, search)
|
||||||
|
- User-level view: tasks assigned to current user across all projects
|
||||||
|
- Project-level view: all tasks in selected project
|
||||||
|
- Filters stored in URL params for shareability/bookmarkability
|
||||||
|
|
||||||
|
**Codebase findings:**
|
||||||
|
|
||||||
|
- 7 widgets already registered in WidgetRegistry.tsx
|
||||||
|
- WidgetGrid.tsx uses react-grid-layout (12-col, 100px row height)
|
||||||
|
- BaseWidget.tsx provides consistent wrapper with loading/error states
|
||||||
|
- Backend widget data endpoints exist (stat-card, chart, list, calendar-preview, active-projects, agent-chains)
|
||||||
|
- UserLayout model ready (workspace+user scoped, JSON layout, metadata)
|
||||||
|
- Widget definitions NOT seeded in DB — need seed data
|
||||||
|
- KnowledgeEntry.contentHtml field exists but unused (ready for WYSIWYG)
|
||||||
|
|
||||||
|
**Task structure: 17 tasks across 5 phases:**
|
||||||
|
|
||||||
|
- Phase 1 (Theme System): Theme architecture + built-in themes + theme UI + ThemeProvider upgrade
|
||||||
|
- Phase 2 (Widget Dashboard): Widget seeding + dashboard migration + picker UI + config UI + layout management
|
||||||
|
- Phase 3 (WYSIWYG): Tiptap setup + markdown round-trip + file manager integration
|
||||||
|
- Phase 4 (Kanban): Filtering + user-level view
|
||||||
|
- Phase 5 (Verification): Tests + theme verification + deploy
|
||||||
|
|
||||||
|
**Estimate:** ~500K tokens total across multiple sessions.
|
||||||
|
|
||||||
|
## Session Log
|
||||||
|
|
||||||
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
|
| ------- | ---------- | --------- | ------------------------- | -------------------------------------------- |
|
||||||
|
| S1 | 2026-02-23 | MS18 | PLAN-001 | Planning complete |
|
||||||
|
| S2 | 2026-02-23 | MS18 | THM-001, THM-002, THM-003 | Theme system complete — PRs #493, #494, #495 |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Widget config schema**: How complex should per-widget configuration be? Start simple (title, data source) and extend later.
|
||||||
|
2. **Theme hot-reload**: ~~Should theme changes apply instantly or require page reload?~~ RESOLVED — Instant via CSS variable injection on html element.
|
||||||
|
3. **Tiptap extensions**: Which extensions to include? Start with: StarterKit, Markdown, Table, CodeBlockLowlight, Link, Image, Placeholder.
|
||||||
|
|
||||||
|
## Corrections
|
||||||
|
|
||||||
|
(none)
|
||||||
151
docs/scratchpads/prd-implementation-20260222.md
Normal file
151
docs/scratchpads/prd-implementation-20260222.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Mission Scratchpad — PRD implementation
|
||||||
|
|
||||||
|
> Append-only log. NEVER delete entries. NEVER overwrite sections.
|
||||||
|
> This is the orchestrator's working memory across sessions.
|
||||||
|
|
||||||
|
## Original Mission Prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
Active mission detected: PRD implementation. Read the mission state files and report status.
|
||||||
|
User instruction: start planning. Be complete and thorough.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Planning Decisions
|
||||||
|
|
||||||
|
### 2026-02-22 — Task Breakdown
|
||||||
|
|
||||||
|
**Decision**: Combined MS16 (Pages) and MS17 (Backend Integration) into a single milestone because 19 backend API modules with 42 controllers already exist. Work is primarily frontend page creation and API wiring.
|
||||||
|
|
||||||
|
**Task structure**: 15 tasks across 4 phases:
|
||||||
|
|
||||||
|
- Phase 1 (Foundation): 404 page + Settings root — quick wins to fill navigation gaps
|
||||||
|
- Phase 2 (Mock Elimination): Wire tasks/calendar/knowledge to real APIs
|
||||||
|
- Phase 3 (New Pages): Projects, Workspace, Kanban, File Manager, Logs, Profile — the bulk of new work
|
||||||
|
- Phase 4 (Verification): Theme/responsive check, docs, deploy + smoke test
|
||||||
|
|
||||||
|
**Branch strategy**: One feature branch per task, PR to main, squash merge.
|
||||||
|
|
||||||
|
**Dependency rationale**:
|
||||||
|
|
||||||
|
- PG-PAGE-002 (Workspace) depends on PG-PAGE-001 (Projects) — workspace shows single project detail, needs project list/selection pattern established first
|
||||||
|
- PG-PAGE-003 (Kanban) depends on PG-API-001 (Tasks API wiring) — Kanban needs the tasks API client to be working with real data before building the board UI
|
||||||
|
- Phase 4 tasks are sequential: verification → docs → deploy
|
||||||
|
|
||||||
|
**Codebase findings** (from exploration):
|
||||||
|
|
||||||
|
- TaskStatus enum: NOT_STARTED, IN_PROGRESS, PAUSED, COMPLETED, ARCHIVED (5 Kanban columns)
|
||||||
|
- Mock data locations: `apps/web/src/lib/api/tasks.ts`, `events.ts`, `knowledge.ts`, `client.ts`
|
||||||
|
- API client pattern: `apiGet<T>()`, `apiPost<T>()`, etc. with CSRF + workspace headers
|
||||||
|
- 17 shared UI components in packages/ui (Button, Card, Badge, Input, Modal, DataTable, etc.)
|
||||||
|
- Sidebar nav defined in `apps/web/src/components/layout/AppSidebar.tsx` — all links already point to correct routes
|
||||||
|
- Design tokens in `apps/web/src/app/globals.css` with full light/dark support
|
||||||
|
|
||||||
|
**Estimate**: ~320K tokens total across all tasks.
|
||||||
|
|
||||||
|
**Issues created**: #466 (Phase 1), #467 (Phase 2), #468 (Phase 3), #469 (Phase 4)
|
||||||
|
**Milestone**: MS16+MS17-PagesDataIntegration (Gitea)
|
||||||
|
|
||||||
|
### Profile Page Addition
|
||||||
|
|
||||||
|
**Decision**: Added PG-PAGE-006 (Profile page) — PRD FR-003 notes "profile page navigation pending (MS16)" and the sidebar user card should link to it. Included in Phase 3.
|
||||||
|
|
||||||
|
## Session Log
|
||||||
|
|
||||||
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
|
| ------- | ---------- | --------- | ------------------------ | ---------------------------------------------------------------------------------------------------- |
|
||||||
|
| S1 | 2026-02-22 | MS16+MS17 | PG-PLAN-001 | Planning complete — milestone created, 4 issues created (#466-#469), 15 tasks populated in TASKS.md |
|
||||||
|
| S1 | 2026-02-22 | MS16+MS17 | PG-FND-001 to PG-VER-001 | 13 implementation tasks completed. PRs #470-#483 merged. 3 test failures fixed. All 5125 tests pass. |
|
||||||
|
| S2 | 2026-02-22 | MS16+MS17 | PG-DOC-001 | Documentation updates — PRD statuses, manifest, scratchpad, TASKS.md. |
|
||||||
|
|
||||||
|
## Open Questions (Resolved)
|
||||||
|
|
||||||
|
1. **Kanban DnD library**: RESOLVED — @hello-pangea/dnd selected (maintained fork of react-beautiful-dnd, better TS support)
|
||||||
|
2. **Workspace page default view**: RESOLVED — Shows project selector when no project param, workspace detail when ?project=id
|
||||||
|
3. **File Manager vs Knowledge**: RESOLVED — File Manager maps directly to Knowledge entries via /api/knowledge. API shape matches UI needs.
|
||||||
|
|
||||||
|
## Execution Summary
|
||||||
|
|
||||||
|
### PRs Merged (14 total)
|
||||||
|
|
||||||
|
| PR | Task | Description |
|
||||||
|
| ---- | ----------- | ---------------------------------------------------------- |
|
||||||
|
| #470 | PG-PLAN-001 | Planning artifacts (TASKS.md, manifest, scratchpad) |
|
||||||
|
| #471 | PG-FND-002 | Settings root page — 4 category cards |
|
||||||
|
| #472 | PG-FND-001 | Custom 404 pages (global + authenticated) |
|
||||||
|
| #473 | PG-API-001 | Tasks page wired to real API |
|
||||||
|
| #474 | PG-API-002 | Calendar page wired to real API |
|
||||||
|
| #475 | PG-API-001 | Tasks API updateTask function |
|
||||||
|
| #476 | PG-API-003 | Knowledge pages wired to real API (238 lines mock removed) |
|
||||||
|
| #477 | PG-PAGE-001 | Projects list page (809 lines) |
|
||||||
|
| #478 | PG-PAGE-003 | Kanban board with DnD |
|
||||||
|
| #479 | PG-PAGE-002 | Project workspace with tabs |
|
||||||
|
| #480 | PG-PAGE-005 | Logs & telemetry page |
|
||||||
|
| #481 | PG-PAGE-004 | File manager page |
|
||||||
|
| #482 | PG-PAGE-006 | Profile page |
|
||||||
|
| #483 | PG-VER-001 | Test fixes (3 failures resolved, 5125 pass) |
|
||||||
|
|
||||||
|
### Issues Closed
|
||||||
|
|
||||||
|
- #466 (Phase 1: Foundation pages)
|
||||||
|
- #467 (Phase 2: Mock elimination)
|
||||||
|
- #468 (Phase 3: New pages)
|
||||||
|
- #469 — pending closure after deploy
|
||||||
|
|
||||||
|
## Deployment Blocker — 2026-02-23
|
||||||
|
|
||||||
|
**Issue**: Traefik proxy does not route HTTPS to the API container (`api.mosaic.woltje.com`) after service restart.
|
||||||
|
|
||||||
|
**Observed behavior**:
|
||||||
|
|
||||||
|
- HTTP (port 80) works for both `mosaic.woltje.com` and `api.mosaic.woltje.com` (301 redirect)
|
||||||
|
- HTTPS to `mosaic.woltje.com` (web) returns 200 — pages render
|
||||||
|
- HTTPS to `api.mosaic.woltje.com` (API) hangs after TLS handshake — zero bytes received
|
||||||
|
- Issue persists through multiple stop/start/restart cycles
|
||||||
|
- Same behavior from external IP and from direct connection to 10.1.1.44
|
||||||
|
- All containers healthy internally (Coolify reports `running:healthy`)
|
||||||
|
- Docker labels for Traefik routing are correct (verified via API)
|
||||||
|
- Go-Live MVP deployment (pre-restart) had both working
|
||||||
|
|
||||||
|
**What IS verified**:
|
||||||
|
|
||||||
|
- CI pipeline #585 green (lint, typecheck, test, build, docker, trivy)
|
||||||
|
- Web container serving latest code — all 10 new page routes return HTTP 200
|
||||||
|
- 404 page renders correctly ("Page Not Found", "Go to Dashboard")
|
||||||
|
- API container healthy (health endpoint works internally)
|
||||||
|
- All 15 PRs merged to main (#470-#484)
|
||||||
|
|
||||||
|
**What is NOT verified** (requires API access from browser):
|
||||||
|
|
||||||
|
- Auth flow (login via Authentik)
|
||||||
|
- Pages rendering with real API data in browser
|
||||||
|
- No console errors during navigation
|
||||||
|
|
||||||
|
**Root cause**: Likely Traefik cert store or internal routing table corruption after container restart. Requires server-level access to inspect Traefik logs (`docker logs coolify-proxy`).
|
||||||
|
|
||||||
|
**Resolution needed**: Jason to check Traefik proxy logs on Coolify server (10.1.1.44). May need Traefik proxy restart or cert store cleanup.
|
||||||
|
|
||||||
|
## Mission Close-Out — 2026-02-23
|
||||||
|
|
||||||
|
**Smoke test**: CONFIRMED by Jason. All pages accessible, auth working, no console errors.
|
||||||
|
|
||||||
|
**Deployment blocker resolution**: Traefik HTTPS routing issue resolved (was intermittent proxy state issue).
|
||||||
|
|
||||||
|
**Close-out actions (S3)**:
|
||||||
|
|
||||||
|
- PRD updated: MS16+MS17 added to Completed Work, acceptance criteria 14-28 marked DONE, risks/open questions marked RESOLVED
|
||||||
|
- Issue #469 closed with completion comment
|
||||||
|
- Manifest updated with S2+S3 session entries
|
||||||
|
- Mission status: **COMPLETE**
|
||||||
|
|
||||||
|
**Final evidence**:
|
||||||
|
|
||||||
|
- 15/15 tasks done
|
||||||
|
- 15 PRs merged (#470-#484)
|
||||||
|
- 4 issues closed (#466-#469)
|
||||||
|
- CI pipeline #585 green
|
||||||
|
- All 5125 tests passing
|
||||||
|
- Deployed and smoke-tested at mosaic.woltje.com
|
||||||
|
|
||||||
|
## Corrections
|
||||||
|
|
||||||
|
(none)
|
||||||
BIN
docs/scratchpads/smoke-test-dashboard.png
Normal file
BIN
docs/scratchpads/smoke-test-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 792 KiB |
@@ -16,19 +16,21 @@ describe("Button", () => {
|
|||||||
it("should apply primary variant styles by default", () => {
|
it("should apply primary variant styles by default", () => {
|
||||||
render(<Button>Primary</Button>);
|
render(<Button>Primary</Button>);
|
||||||
const button = screen.getByRole("button");
|
const button = screen.getByRole("button");
|
||||||
expect(button.className).toContain("bg-blue-600");
|
expect(button.style.background).toBe("var(--ms-blue-500)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should apply secondary variant styles", () => {
|
it("should apply secondary variant styles", () => {
|
||||||
render(<Button variant="secondary">Secondary</Button>);
|
render(<Button variant="secondary">Secondary</Button>);
|
||||||
const button = screen.getByRole("button");
|
const button = screen.getByRole("button");
|
||||||
expect(button.className).toContain("bg-gray-200");
|
expect(button.style.background).toBe("transparent");
|
||||||
|
expect(button.style.border).toBe("1px solid var(--border)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should apply danger variant styles", () => {
|
it("should apply danger variant styles", () => {
|
||||||
render(<Button variant="danger">Delete</Button>);
|
render(<Button variant="danger">Delete</Button>);
|
||||||
const button = screen.getByRole("button");
|
const button = screen.getByRole("button");
|
||||||
expect(button.className).toContain("bg-red-600");
|
expect(button.style.background).toBe("rgba(229, 72, 77, 0.12)");
|
||||||
|
expect(button.style.color).toBe("var(--danger)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +83,6 @@ describe("Button", () => {
|
|||||||
render(<Button className="custom-class">Custom</Button>);
|
render(<Button className="custom-class">Custom</Button>);
|
||||||
const button = screen.getByRole("button");
|
const button = screen.getByRole("button");
|
||||||
expect(button.className).toContain("custom-class");
|
expect(button.className).toContain("custom-class");
|
||||||
expect(button.className).toContain("bg-blue-600");
|
expect(button.style.background).toBe("var(--ms-blue-500)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface MetricsStripProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean }): ReactElement {
|
function MetricCellItem({ cell }: { cell: MetricCell }): ReactElement {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
const trendColor =
|
const trendColor =
|
||||||
@@ -28,6 +28,7 @@ function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="metric-cell"
|
||||||
onMouseEnter={(): void => {
|
onMouseEnter={(): void => {
|
||||||
setHovered(true);
|
setHovered(true);
|
||||||
}}
|
}}
|
||||||
@@ -37,7 +38,6 @@ function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean
|
|||||||
style={{
|
style={{
|
||||||
padding: "14px 16px",
|
padding: "14px 16px",
|
||||||
background: hovered ? "var(--surface-2)" : "var(--surface)",
|
background: hovered ? "var(--surface-2)" : "var(--surface)",
|
||||||
borderLeft: isFirst ? "none" : "1px solid var(--border)",
|
|
||||||
borderTop: `2px solid ${cell.color}`,
|
borderTop: `2px solid ${cell.color}`,
|
||||||
transition: "background 0.15s ease",
|
transition: "background 0.15s ease",
|
||||||
}}
|
}}
|
||||||
@@ -82,17 +82,15 @@ function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean
|
|||||||
export function MetricsStrip({ cells, className = "" }: MetricsStripProps): ReactElement {
|
export function MetricsStrip({ cells, className = "" }: MetricsStripProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={`metrics-strip ${className}`.trim()}
|
||||||
style={{
|
style={
|
||||||
display: "grid",
|
{
|
||||||
gridTemplateColumns: `repeat(${String(cells.length)}, 1fr)`,
|
"--ms-cols": String(cells.length),
|
||||||
borderRadius: "var(--r-lg)",
|
} as React.CSSProperties
|
||||||
overflow: "hidden",
|
}
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{cells.map((cell, index) => (
|
{cells.map((cell) => (
|
||||||
<MetricCellItem key={cell.label} cell={cell} isFirst={index === 0} />
|
<MetricCellItem key={cell.label} cell={cell} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
816
pnpm-lock.yaml
generated
816
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,33 @@ source "$SCRIPT_DIR/common.sh"
|
|||||||
ensure_repo_root
|
ensure_repo_root
|
||||||
load_repo_hooks
|
load_repo_hooks
|
||||||
|
|
||||||
|
# ─── Mission session cleanup (ORCHESTRATOR-PROTOCOL) ────────────────────────
|
||||||
|
ORCH_DIR=".mosaic/orchestrator"
|
||||||
|
MISSION_JSON="$ORCH_DIR/mission.json"
|
||||||
|
SESSION_LOCK="$ORCH_DIR/session.lock"
|
||||||
|
COORD_LIB="$HOME/.config/mosaic/tools/orchestrator/_lib.sh"
|
||||||
|
|
||||||
|
if [[ -f "$SESSION_LOCK" ]] && [[ -f "$COORD_LIB" ]] && command -v jq &>/dev/null; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$COORD_LIB"
|
||||||
|
|
||||||
|
sess_id="$(jq -r '.session_id // ""' "$SESSION_LOCK")"
|
||||||
|
if [[ -n "$sess_id" && -f "$MISSION_JSON" ]]; then
|
||||||
|
updated="$(jq \
|
||||||
|
--arg sid "$sess_id" \
|
||||||
|
--arg ts "$(iso_now)" \
|
||||||
|
--arg reason "completed" \
|
||||||
|
'(.sessions[] | select(.session_id == $sid)) |= . + {
|
||||||
|
ended_at: $ts,
|
||||||
|
ended_reason: $reason
|
||||||
|
}' "$MISSION_JSON")"
|
||||||
|
echo "$updated" > "$MISSION_JSON.tmp" && mv "$MISSION_JSON.tmp" "$MISSION_JSON"
|
||||||
|
echo "[agent-framework] Session $sess_id recorded in mission state"
|
||||||
|
fi
|
||||||
|
|
||||||
|
session_lock_clear "."
|
||||||
|
fi
|
||||||
|
|
||||||
if declare -F mosaic_hook_session_end >/dev/null 2>&1; then
|
if declare -F mosaic_hook_session_end >/dev/null 2>&1; then
|
||||||
run_step "Run repo end hook" mosaic_hook_session_end
|
run_step "Run repo end hook" mosaic_hook_session_end
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -43,6 +43,70 @@ if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && has_remote; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ─── Mission state detection (ORCHESTRATOR-PROTOCOL) ────────────────────────
|
||||||
|
ORCH_DIR=".mosaic/orchestrator"
|
||||||
|
MISSION_JSON="$ORCH_DIR/mission.json"
|
||||||
|
COORD_LIB="$HOME/.config/mosaic/tools/orchestrator/_lib.sh"
|
||||||
|
|
||||||
|
if [[ -f "$MISSION_JSON" ]] && command -v jq &>/dev/null; then
|
||||||
|
mission_status="$(jq -r '.status // "inactive"' "$MISSION_JSON")"
|
||||||
|
|
||||||
|
if [[ "$mission_status" == "active" || "$mission_status" == "paused" ]]; then
|
||||||
|
mission_name="$(jq -r '.name // "unnamed"' "$MISSION_JSON")"
|
||||||
|
echo ""
|
||||||
|
echo "========================================="
|
||||||
|
echo "ACTIVE MISSION DETECTED"
|
||||||
|
echo "========================================="
|
||||||
|
echo " Mission: $mission_name"
|
||||||
|
|
||||||
|
manifest="docs/MISSION-MANIFEST.md"
|
||||||
|
if [[ -f "$manifest" ]]; then
|
||||||
|
phase="$(grep -m1 '^\*\*Phase:\*\*' "$manifest" 2>/dev/null | sed 's/.*\*\*Phase:\*\* //' || true)"
|
||||||
|
milestone="$(grep -m1 '^\*\*Current Milestone:\*\*' "$manifest" 2>/dev/null | sed 's/.*\*\*Current Milestone:\*\* //' || true)"
|
||||||
|
progress="$(grep -m1 '^\*\*Progress:\*\*' "$manifest" 2>/dev/null | sed 's/.*\*\*Progress:\*\* //' || true)"
|
||||||
|
[[ -n "$phase" ]] && echo " Phase: $phase"
|
||||||
|
[[ -n "$milestone" ]] && echo " Milestone: $milestone"
|
||||||
|
[[ -n "$progress" ]] && echo " Progress: $progress"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "docs/TASKS.md" ]]; then
|
||||||
|
total="$(grep -c '^|' "docs/TASKS.md" 2>/dev/null || true)"
|
||||||
|
total="${total:-0}"
|
||||||
|
done_count="$(grep -ci '| done \|| completed ' "docs/TASKS.md" 2>/dev/null || true)"
|
||||||
|
done_count="${done_count:-0}"
|
||||||
|
approx_total=$(( total > 2 ? total - 2 : 0 ))
|
||||||
|
echo " Tasks: ~${done_count} done of ~${approx_total} total"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d "docs/scratchpads" ]]; then
|
||||||
|
latest_sp="$(ls -t docs/scratchpads/*.md 2>/dev/null | head -1 || true)"
|
||||||
|
[[ -n "$latest_sp" ]] && echo " Scratchpad: $latest_sp"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Resume: Read manifest + scratchpad before taking action."
|
||||||
|
echo " Protocol: ~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md"
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ -f "$COORD_LIB" ]]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$COORD_LIB"
|
||||||
|
sess_id="$(next_session_id ".")"
|
||||||
|
runtime="${MOSAIC_RUNTIME:-unknown}"
|
||||||
|
session_lock_write "." "$sess_id" "$runtime" "$$"
|
||||||
|
|
||||||
|
updated="$(jq \
|
||||||
|
--arg sid "$sess_id" \
|
||||||
|
--arg rt "$runtime" \
|
||||||
|
--arg ts "$(iso_now)" \
|
||||||
|
'.sessions += [{"session_id":$sid,"runtime":$rt,"started_at":$ts,"ended_at":"","ended_reason":"","milestone_at_end":"","tasks_completed":[],"last_task_id":""}]' \
|
||||||
|
"$MISSION_JSON")"
|
||||||
|
echo "$updated" > "$MISSION_JSON.tmp" && mv "$MISSION_JSON.tmp" "$MISSION_JSON"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if declare -F mosaic_hook_session_start >/dev/null 2>&1; then
|
if declare -F mosaic_hook_session_start >/dev/null 2>&1; then
|
||||||
run_step "Run repo start hook" mosaic_hook_session_start
|
run_step "Run repo start hook" mosaic_hook_session_start
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user