Compare commits

...

46 Commits

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:41:43 -06:00
7de0e734b0 feat(web): implement SSE chat streaming with real-time token rendering (#516)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 02:39:43 +00:00
6290fc3d53 feat(api): add terminal WebSocket gateway with PTY session management (#515)
Some checks failed
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/orchestrator Pipeline failed
ci/woodpecker/push/api Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 02:27:29 +00:00
9f4de1682f fix(api): resolve CSRF guard ordering with global AuthGuard (#514)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 02:26:02 +00:00
374ca7ace3 docs: initialize MS19 Chat & Terminal mission planning (#513)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 01:49:40 +00:00
72c64d2eeb fix(api): add global /api prefix to resolve frontend route mismatch (#507)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 01:13:48 +00:00
5f6c520a98 fix(auth): prevent login page freeze on OAuth sign-in failure (#506)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-25 01:59:36 +00:00
9a7673bea2 docs: close out MS18 Theme & Widget System mission (#505)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 03:01:54 +00:00
91934b9933 docs: update mission artifacts for MS18 completion (#504)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 02:29:06 +00:00
7f89682946 test(web): add unit tests for MS18 components (#503)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 02:23:05 +00:00
8b4c565f20 feat(web): add kanban board filtering with URL param persistence (#502)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 02:09:37 +00:00
d5ecc0b107 feat(web): add markdown round-trip and replace textarea with Tiptap (#501)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 01:40:34 +00:00
a81c4a5edd feat(web): add Tiptap WYSIWYG KnowledgeEditor component (#500)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 01:23:57 +00:00
ff5a09c3fb feat(web): add widget config dialog and layout management controls (#499)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 01:11:47 +00:00
f93fa60fff feat(web): add widget picker drawer for dashboard customization (#498)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 00:59:45 +00:00
cc56f2cbe1 feat(web): migrate dashboard to WidgetGrid with layout persistence (#497)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 00:50:24 +00:00
f9cccd6965 feat(api): seed 7 widget definitions for dashboard system (#496)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 00:28:02 +00:00
90c3bbccdf feat(web): add theme selection UI in Settings > Appearance (#495)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 14:18:16 +00:00
79286e98c6 feat(web): upgrade ThemeProvider for multi-theme registry (#494)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 14:09:10 +00:00
cfd1def4a9 feat(web): add theme definition system with 5 built-in themes (#493)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 13:59:01 +00:00
f435d8e8c6 docs: initialize MS18 Theme & Widget System mission (#492)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 13:36:10 +00:00
3d78b09064 docs: close out MS16+MS17 mission (#486)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 13:27:22 +00:00
a7955b9b32 docs: mark MS16+MS17 milestone complete (#485)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 13:16:38 +00:00
372cc100cc docs: update PRD statuses and mission artifacts for MS16+MS17 (#484)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 05:09:04 +00:00
37cf813b88 fix(web): update calendar and knowledge tests for real API integration (#483)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 05:04:55 +00:00
3d5b50af11 feat(web): add profile page with user info and preferences (#482)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:50:44 +00:00
f30c2f790c feat(web): add file manager page with list/grid views (#481)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:39:19 +00:00
05b1a93ccb feat(web): add logs and telemetry page with filtering and auto-refresh (#480)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:38:15 +00:00
a78a8b88e1 feat(web): add project workspace page with tasks and agent sessions (#479)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:29:39 +00:00
172ed1d40f feat(web): add kanban board page with drag-and-drop (#478)
Some checks failed
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:26:25 +00:00
ee2ddfc8b8 feat(web): add projects page with CRUD operations (#477)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:13:26 +00:00
5a6d00a064 feat(web): wire knowledge pages to real API data (#476)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:12:14 +00:00
ffda74ec12 test(web): update tasks page tests for real API integration (#475)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 03:59:56 +00:00
f97be2e6a3 feat(web): wire calendar page to real API data (#474)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 03:51:15 +00:00
97606713b5 feat(web): wire tasks page to real API data (#473)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 03:51:08 +00:00
d0c720e6da feat(web): add custom 404 pages for global and authenticated routes (#472)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 03:43:55 +00:00
64e817cfb8 feat(web): add settings root index page with category cards (#471)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 03:42:01 +00:00
cd5c2218c8 chore(orchestrator): bootstrap MS16+MS17 planning (#470)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 03:29:53 +00:00
f643d2bc04 docs: mark mission complete (MS-P4-003) (#465)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 02:11:13 +00:00
8957904ea9 Phase 4: Deploy + Smoke Test (#463) (#464)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 02:09:43 +00:00
458cac7cdd Phase 3: Agent Cycle Visibility (#461) (#462)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 01:07:29 +00:00
7581d26567 Phase 2: Task Ingestion Pipeline (#459) (#460)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 00:54:55 +00:00
07f5225a76 Phase 1: Dashboard Polish + Theming (#457) (#458)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 00:16:45 +00:00
7c55464d54 fix: add mission detection to session hooks (#456)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 23:42:21 +00:00
ea1620fa7a docs: initialize go-live MVP mission with coordinator protocol (#455)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 23:37:13 +00:00
131 changed files with 16274 additions and 2101 deletions

View 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": []
}

View File

@@ -66,6 +66,7 @@
"marked-gfm-heading-id": "^4.1.3", "marked-gfm-heading-id": "^4.1.3",
"marked-highlight": "^2.2.3", "marked-highlight": "^2.2.3",
"matrix-bot-sdk": "^0.8.0", "matrix-bot-sdk": "^0.8.0",
"node-pty": "^1.0.0",
"ollama": "^0.6.3", "ollama": "^0.6.3",
"openai": "^6.17.0", "openai": "^6.17.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",

View File

@@ -0,0 +1,23 @@
-- CreateEnum
CREATE TYPE "TerminalSessionStatus" AS ENUM ('ACTIVE', 'CLOSED');
-- CreateTable
CREATE TABLE "terminal_sessions" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL DEFAULT 'Terminal',
"status" "TerminalSessionStatus" NOT NULL DEFAULT 'ACTIVE',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closed_at" TIMESTAMPTZ,
CONSTRAINT "terminal_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "terminal_sessions_workspace_id_idx" ON "terminal_sessions"("workspace_id");
-- CreateIndex
CREATE INDEX "terminal_sessions_workspace_id_status_idx" ON "terminal_sessions"("workspace_id", "status");
-- AddForeignKey
ALTER TABLE "terminal_sessions" ADD CONSTRAINT "terminal_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -206,6 +206,11 @@ enum CredentialScope {
SYSTEM SYSTEM
} }
enum TerminalSessionStatus {
ACTIVE
CLOSED
}
// ============================================ // ============================================
// MODELS // MODELS
// ============================================ // ============================================
@@ -297,6 +302,7 @@ model Workspace {
federationEventSubscriptions FederationEventSubscription[] federationEventSubscriptions FederationEventSubscription[]
llmUsageLogs LlmUsageLog[] llmUsageLogs LlmUsageLog[]
userCredentials UserCredential[] userCredentials UserCredential[]
terminalSessions TerminalSession[]
@@index([ownerId]) @@index([ownerId])
@@map("workspaces") @@map("workspaces")
@@ -1507,3 +1513,23 @@ model LlmUsageLog {
@@index([conversationId]) @@index([conversationId])
@@map("llm_usage_logs") @@map("llm_usage_logs")
} }
// ============================================
// TERMINAL MODULE
// ============================================
model TerminalSession {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
name String @default("Terminal")
status TerminalSessionStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
closedAt DateTime? @map("closed_at") @db.Timestamptz
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([workspaceId])
@@index([workspaceId, status])
@@map("terminal_sessions")
}

View File

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

View File

@@ -39,6 +39,8 @@ 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 { TerminalModule } from "./terminal/terminal.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
@Module({ @Module({
@@ -101,6 +103,8 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
CredentialsModule, CredentialsModule,
MosaicTelemetryModule, MosaicTelemetryModule,
SpeechModule, SpeechModule,
DashboardModule,
TerminalModule,
], ],
controllers: [AppController, CsrfController], controllers: [AppController, CsrfController],
providers: [ providers: [

View File

@@ -254,6 +254,10 @@ export function createAuth(prisma: PrismaClient) {
enabled: true, enabled: true,
}, },
plugins: [...getOidcPlugins()], plugins: [...getOidcPlugins()],
logger: {
disabled: false,
level: "error",
},
session: { session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request

View File

@@ -123,6 +123,14 @@ export class AuthController {
try { try {
await handler(req, res); await handler(req, res);
// BetterAuth writes responses directly — catch silent 500s that bypass NestJS error handling
if (res.statusCode >= 500) {
this.logger.error(
`BetterAuth returned ${String(res.statusCode)} for ${req.method} ${req.url} from ${clientIp}` +
` — check container stdout for '# SERVER_ERROR' details`
);
}
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined; const stack = error instanceof Error ? error.stack : undefined;

View File

@@ -16,7 +16,7 @@ interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser; user?: AuthenticatedUser;
} }
@Controller("api/v1/csrf") @Controller("v1/csrf")
export class CsrfController { export class CsrfController {
constructor(private readonly csrfService: CsrfService) {} constructor(private readonly csrfService: CsrfService) {}

View File

@@ -174,17 +174,19 @@ describe("CsrfGuard", () => {
}); });
describe("Session binding validation", () => { describe("Session binding validation", () => {
it("should reject when user is not authenticated", () => { it("should allow when user context is not yet available (global guard ordering)", () => {
// CsrfGuard runs as APP_GUARD before per-controller AuthGuard,
// so request.user may not be populated. Double-submit cookie match
// is sufficient protection in this case.
const token = generateValidToken("user-123"); const token = generateValidToken("user-123");
const context = createContext( const context = createContext(
"POST", "POST",
{ "csrf-token": token }, { "csrf-token": token },
{ "x-csrf-token": token }, { "x-csrf-token": token },
false false
// No userId - unauthenticated // No userId - AuthGuard hasn't run yet
); );
expect(() => guard.canActivate(context)).toThrow(ForbiddenException); expect(guard.canActivate(context)).toBe(true);
expect(() => guard.canActivate(context)).toThrow("CSRF validation requires authentication");
}); });
it("should reject token from different session", () => { it("should reject token from different session", () => {

View File

@@ -89,30 +89,30 @@ export class CsrfGuard implements CanActivate {
throw new ForbiddenException("CSRF token mismatch"); throw new ForbiddenException("CSRF token mismatch");
} }
// Validate session binding via HMAC // Validate session binding via HMAC when user context is available.
// CsrfGuard is a global guard (APP_GUARD) that runs before per-controller
// AuthGuard, so request.user may not be populated yet. In that case, the
// double-submit cookie match above is sufficient CSRF protection.
const userId = request.user?.id; const userId = request.user?.id;
if (!userId) { if (userId) {
this.logger.warn({ if (!this.csrfService.validateToken(cookieToken, userId)) {
event: "CSRF_NO_USER_CONTEXT", this.logger.warn({
event: "CSRF_SESSION_BINDING_INVALID",
method: request.method,
path: request.path,
securityEvent: true,
timestamp: new Date().toISOString(),
});
throw new ForbiddenException("CSRF token not bound to session");
}
} else {
this.logger.debug({
event: "CSRF_SKIP_SESSION_BINDING",
method: request.method, method: request.method,
path: request.path, path: request.path,
securityEvent: true, reason: "User context not yet available (global guard runs before AuthGuard)",
timestamp: new Date().toISOString(),
}); });
throw new ForbiddenException("CSRF validation requires authentication");
}
if (!this.csrfService.validateToken(cookieToken, userId)) {
this.logger.warn({
event: "CSRF_SESSION_BINDING_INVALID",
method: request.method,
path: request.path,
securityEvent: true,
timestamp: new Date().toISOString(),
});
throw new ForbiddenException("CSRF token not bound to session");
} }
return true; return true;

View File

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

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

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

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

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

View File

@@ -0,0 +1 @@
export * from "./dashboard-summary.dto";

View File

@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
import type { CommandMessageDetails, CommandResponse } from "./types/message.types"; import type { CommandMessageDetails, CommandResponse } from "./types/message.types";
import type { FederationMessageStatus } from "@prisma/client"; import type { FederationMessageStatus } from "@prisma/client";
@Controller("api/v1/federation") @Controller("v1/federation")
export class CommandController { export class CommandController {
private readonly logger = new Logger(CommandController.name); private readonly logger = new Logger(CommandController.name);

View File

@@ -23,7 +23,7 @@ import {
IncomingEventAckDto, IncomingEventAckDto,
} from "./dto/event.dto"; } from "./dto/event.dto";
@Controller("api/v1/federation") @Controller("v1/federation")
export class EventController { export class EventController {
private readonly logger = new Logger(EventController.name); private readonly logger = new Logger(EventController.name);

View File

@@ -18,7 +18,7 @@ import {
ValidateFederatedTokenDto, ValidateFederatedTokenDto,
} from "./dto/federated-auth.dto"; } from "./dto/federated-auth.dto";
@Controller("api/v1/federation/auth") @Controller("v1/federation/auth")
export class FederationAuthController { export class FederationAuthController {
private readonly logger = new Logger(FederationAuthController.name); private readonly logger = new Logger(FederationAuthController.name);

View File

@@ -27,7 +27,7 @@ import {
} from "./dto/connection.dto"; } from "./dto/connection.dto";
import { FederationConnectionStatus } from "@prisma/client"; import { FederationConnectionStatus } from "@prisma/client";
@Controller("api/v1/federation") @Controller("v1/federation")
export class FederationController { export class FederationController {
private readonly logger = new Logger(FederationController.name); private readonly logger = new Logger(FederationController.name);

View File

@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
import type { QueryMessageDetails, QueryResponse } from "./types/message.types"; import type { QueryMessageDetails, QueryResponse } from "./types/message.types";
import type { FederationMessageStatus } from "@prisma/client"; import type { FederationMessageStatus } from "@prisma/client";
@Controller("api/v1/federation") @Controller("v1/federation")
export class QueryController { export class QueryController {
private readonly logger = new Logger(QueryController.name); private readonly logger = new Logger(QueryController.name);

View File

@@ -1,5 +1,5 @@
import { NestFactory } from "@nestjs/core"; import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common"; import { RequestMethod, ValidationPipe } from "@nestjs/common";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
import { getTrustedOrigins } from "./auth/auth.config"; import { getTrustedOrigins } from "./auth/auth.config";
@@ -47,6 +47,16 @@ async function bootstrap() {
app.useGlobalFilters(new GlobalExceptionFilter()); app.useGlobalFilters(new GlobalExceptionFilter());
// Set global API prefix — all routes get /api/* except auth and health
// Auth routes are excluded because BetterAuth expects /auth/* paths
// Health is excluded because Docker healthchecks hit /health directly
app.setGlobalPrefix("api", {
exclude: [
{ path: "health", method: RequestMethod.GET },
{ path: "auth/(.*)", method: RequestMethod.ALL },
],
});
// Configure CORS for cookie-based authentication // Configure CORS for cookie-based authentication
// Origin list is shared with BetterAuth trustedOrigins via getTrustedOrigins() // Origin list is shared with BetterAuth trustedOrigins via getTrustedOrigins()
const trustedOrigins = getTrustedOrigins(); const trustedOrigins = getTrustedOrigins();

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
/**
* Terminal Session DTOs
*
* Data Transfer Objects for terminal session persistence endpoints.
* Validated using class-validator decorators.
*/
import { IsString, IsOptional, MaxLength, IsEnum, IsUUID } from "class-validator";
import { TerminalSessionStatus } from "@prisma/client";
/**
* DTO for creating a new terminal session record.
*/
export class CreateTerminalSessionDto {
@IsString()
@IsUUID()
workspaceId!: string;
@IsOptional()
@IsString()
@MaxLength(128)
name?: string;
}
/**
* DTO for querying terminal sessions by workspace.
*/
export class FindTerminalSessionsByWorkspaceDto {
@IsString()
@IsUUID()
workspaceId!: string;
}
/**
* Response shape for a terminal session.
*/
export class TerminalSessionResponseDto {
id!: string;
workspaceId!: string;
name!: string;
status!: TerminalSessionStatus;
createdAt!: Date;
closedAt!: Date | null;
}
/**
* DTO for filtering terminal sessions by status.
*/
export class TerminalSessionStatusFilterDto {
@IsOptional()
@IsEnum(TerminalSessionStatus)
status?: TerminalSessionStatus;
}

View File

@@ -0,0 +1,229 @@
/**
* TerminalSessionService Tests
*
* Unit tests for database-backed terminal session CRUD:
* create, findByWorkspace, close, and findById.
* PrismaService is mocked to isolate the service logic.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NotFoundException } from "@nestjs/common";
import { TerminalSessionStatus } from "@prisma/client";
import type { TerminalSession } from "@prisma/client";
import { TerminalSessionService } from "./terminal-session.service";
// ==========================================
// Helpers
// ==========================================
function makeSession(overrides: Partial<TerminalSession> = {}): TerminalSession {
return {
id: "session-uuid-1",
workspaceId: "workspace-uuid-1",
name: "Terminal",
status: TerminalSessionStatus.ACTIVE,
createdAt: new Date("2026-02-25T00:00:00Z"),
closedAt: null,
...overrides,
};
}
// ==========================================
// Mock PrismaService
// ==========================================
function makeMockPrisma() {
return {
terminalSession: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
};
}
// ==========================================
// Tests
// ==========================================
describe("TerminalSessionService", () => {
let service: TerminalSessionService;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mockPrisma: any;
beforeEach(() => {
vi.clearAllMocks();
mockPrisma = makeMockPrisma();
service = new TerminalSessionService(mockPrisma);
});
// ==========================================
// create
// ==========================================
describe("create", () => {
it("should call prisma.terminalSession.create with workspaceId only when no name provided", async () => {
const session = makeSession();
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
const result = await service.create("workspace-uuid-1");
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
data: { workspaceId: "workspace-uuid-1" },
});
expect(result).toEqual(session);
});
it("should include name in create data when name is provided", async () => {
const session = makeSession({ name: "My Terminal" });
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
const result = await service.create("workspace-uuid-1", "My Terminal");
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
data: { workspaceId: "workspace-uuid-1", name: "My Terminal" },
});
expect(result).toEqual(session);
});
it("should return the created session", async () => {
const session = makeSession();
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
const result = await service.create("workspace-uuid-1");
expect(result.id).toBe("session-uuid-1");
expect(result.status).toBe(TerminalSessionStatus.ACTIVE);
});
});
// ==========================================
// findByWorkspace
// ==========================================
describe("findByWorkspace", () => {
it("should query for ACTIVE sessions in the given workspace, ordered by createdAt desc", async () => {
const sessions = [makeSession(), makeSession({ id: "session-uuid-2" })];
mockPrisma.terminalSession.findMany.mockResolvedValueOnce(sessions);
const result = await service.findByWorkspace("workspace-uuid-1");
expect(mockPrisma.terminalSession.findMany).toHaveBeenCalledWith({
where: {
workspaceId: "workspace-uuid-1",
status: TerminalSessionStatus.ACTIVE,
},
orderBy: { createdAt: "desc" },
});
expect(result).toHaveLength(2);
});
it("should return an empty array when no active sessions exist", async () => {
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
const result = await service.findByWorkspace("workspace-uuid-empty");
expect(result).toEqual([]);
});
it("should not include CLOSED sessions", async () => {
// The where clause enforces ACTIVE status — verify it is present
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
await service.findByWorkspace("workspace-uuid-1");
const callArgs = mockPrisma.terminalSession.findMany.mock.calls[0][0] as {
where: { status: TerminalSessionStatus };
};
expect(callArgs.where.status).toBe(TerminalSessionStatus.ACTIVE);
});
});
// ==========================================
// close
// ==========================================
describe("close", () => {
it("should set status to CLOSED and set closedAt when session exists", async () => {
const existingSession = makeSession();
const closedSession = makeSession({
status: TerminalSessionStatus.CLOSED,
closedAt: new Date("2026-02-25T01:00:00Z"),
});
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
const result = await service.close("session-uuid-1");
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
where: { id: "session-uuid-1" },
});
expect(mockPrisma.terminalSession.update).toHaveBeenCalledWith({
where: { id: "session-uuid-1" },
data: {
status: TerminalSessionStatus.CLOSED,
closedAt: expect.any(Date),
},
});
expect(result.status).toBe(TerminalSessionStatus.CLOSED);
});
it("should throw NotFoundException when session does not exist", async () => {
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
await expect(service.close("nonexistent-id")).rejects.toThrow(NotFoundException);
expect(mockPrisma.terminalSession.update).not.toHaveBeenCalled();
});
it("should include a non-null closedAt timestamp on close", async () => {
const existingSession = makeSession();
const closedSession = makeSession({
status: TerminalSessionStatus.CLOSED,
closedAt: new Date(),
});
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
const result = await service.close("session-uuid-1");
expect(result.closedAt).not.toBeNull();
});
});
// ==========================================
// findById
// ==========================================
describe("findById", () => {
it("should return the session when it exists", async () => {
const session = makeSession();
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(session);
const result = await service.findById("session-uuid-1");
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
where: { id: "session-uuid-1" },
});
expect(result).toEqual(session);
});
it("should return null when session does not exist", async () => {
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
const result = await service.findById("no-such-id");
expect(result).toBeNull();
});
it("should find CLOSED sessions as well as ACTIVE ones", async () => {
const closedSession = makeSession({
status: TerminalSessionStatus.CLOSED,
closedAt: new Date(),
});
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(closedSession);
const result = await service.findById("session-uuid-1");
expect(result?.status).toBe(TerminalSessionStatus.CLOSED);
});
});
});

View File

@@ -0,0 +1,96 @@
/**
* TerminalSessionService
*
* Manages database persistence for terminal sessions.
* Provides CRUD operations on the TerminalSession model,
* enabling session tracking, recovery, and workspace-level listing.
*
* Session lifecycle:
* - create: record a new terminal session with ACTIVE status
* - findByWorkspace: return all ACTIVE sessions for a workspace
* - close: mark a session as CLOSED, set closedAt timestamp
* - findById: retrieve a single session by ID
*/
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
import { TerminalSessionStatus } from "@prisma/client";
import type { TerminalSession } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class TerminalSessionService {
private readonly logger = new Logger(TerminalSessionService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new terminal session record in the database.
*
* @param workspaceId - The workspace this session belongs to
* @param name - Optional display name for the session (defaults to "Terminal")
* @returns The created TerminalSession record
*/
async create(workspaceId: string, name?: string): Promise<TerminalSession> {
this.logger.log(
`Creating terminal session for workspace ${workspaceId}${name !== undefined ? ` (name: ${name})` : ""}`
);
const data: { workspaceId: string; name?: string } = { workspaceId };
if (name !== undefined) {
data.name = name;
}
return this.prisma.terminalSession.create({ data });
}
/**
* Find all ACTIVE terminal sessions for a workspace.
*
* @param workspaceId - The workspace to query
* @returns Array of active TerminalSession records, ordered by creation time (newest first)
*/
async findByWorkspace(workspaceId: string): Promise<TerminalSession[]> {
return this.prisma.terminalSession.findMany({
where: {
workspaceId,
status: TerminalSessionStatus.ACTIVE,
},
orderBy: { createdAt: "desc" },
});
}
/**
* Close a terminal session by setting its status to CLOSED and recording closedAt.
*
* @param id - The session ID to close
* @returns The updated TerminalSession record
* @throws NotFoundException if the session does not exist
*/
async close(id: string): Promise<TerminalSession> {
const existing = await this.prisma.terminalSession.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException(`Terminal session ${id} not found`);
}
this.logger.log(`Closing terminal session ${id} (workspace: ${existing.workspaceId})`);
return this.prisma.terminalSession.update({
where: { id },
data: {
status: TerminalSessionStatus.CLOSED,
closedAt: new Date(),
},
});
}
/**
* Find a terminal session by ID.
*
* @param id - The session ID to retrieve
* @returns The TerminalSession record, or null if not found
*/
async findById(id: string): Promise<TerminalSession | null> {
return this.prisma.terminalSession.findUnique({ where: { id } });
}
}

View File

@@ -0,0 +1,89 @@
/**
* Terminal DTOs
*
* Data Transfer Objects for terminal WebSocket events.
* Validated using class-validator decorators.
*/
import {
IsString,
IsOptional,
IsNumber,
IsInt,
Min,
Max,
MinLength,
MaxLength,
} from "class-validator";
/**
* DTO for creating a new terminal PTY session.
*/
export class CreateTerminalDto {
@IsOptional()
@IsString()
@MaxLength(128)
name?: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(500)
cols?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(200)
rows?: number;
@IsOptional()
@IsString()
@MaxLength(4096)
cwd?: string;
}
/**
* DTO for sending input data to a terminal PTY session.
*/
export class TerminalInputDto {
@IsString()
@MinLength(1)
@MaxLength(64)
sessionId!: string;
@IsString()
data!: string;
}
/**
* DTO for resizing a terminal PTY session.
*/
export class TerminalResizeDto {
@IsString()
@MinLength(1)
@MaxLength(64)
sessionId!: string;
@IsNumber()
@IsInt()
@Min(1)
@Max(500)
cols!: number;
@IsNumber()
@IsInt()
@Min(1)
@Max(200)
rows!: number;
}
/**
* DTO for closing a terminal PTY session.
*/
export class CloseTerminalDto {
@IsString()
@MinLength(1)
@MaxLength(64)
sessionId!: string;
}

View File

@@ -0,0 +1,501 @@
/**
* TerminalGateway Tests
*
* Unit tests for WebSocket terminal gateway:
* - Authentication on connection
* - terminal:create event handling
* - terminal:input event handling
* - terminal:resize event handling
* - terminal:close event handling
* - disconnect cleanup
* - Error paths
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import type { Socket } from "socket.io";
import { TerminalGateway } from "./terminal.gateway";
import { TerminalService } from "./terminal.service";
import { AuthService } from "../auth/auth.service";
import { PrismaService } from "../prisma/prisma.service";
// ==========================================
// Mocks
// ==========================================
// Mock node-pty globally so TerminalService doesn't fail to import
vi.mock("node-pty", () => ({
spawn: vi.fn(() => ({
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
pid: 1000,
})),
}));
interface AuthenticatedSocket extends Socket {
data: {
userId?: string;
workspaceId?: string;
};
}
function createMockSocket(id = "test-socket-id"): AuthenticatedSocket {
return {
id,
emit: vi.fn(),
join: vi.fn(),
leave: vi.fn(),
disconnect: vi.fn(),
data: {},
handshake: {
auth: { token: "valid-token" },
query: {},
headers: {},
},
} as unknown as AuthenticatedSocket;
}
function createMockAuthService() {
return {
verifySession: vi.fn().mockResolvedValue({
user: { id: "user-123" },
session: { id: "session-123" },
}),
};
}
function createMockPrismaService() {
return {
workspaceMember: {
findFirst: vi.fn().mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
}),
},
};
}
function createMockTerminalService() {
return {
createSession: vi.fn().mockReturnValue({
sessionId: "session-uuid-1",
name: undefined,
cols: 80,
rows: 24,
}),
writeToSession: vi.fn(),
resizeSession: vi.fn(),
closeSession: vi.fn().mockReturnValue(true),
closeWorkspaceSessions: vi.fn(),
sessionBelongsToWorkspace: vi.fn().mockReturnValue(true),
getWorkspaceSessionCount: vi.fn().mockReturnValue(0),
};
}
// ==========================================
// Tests
// ==========================================
describe("TerminalGateway", () => {
let gateway: TerminalGateway;
let mockAuthService: ReturnType<typeof createMockAuthService>;
let mockPrismaService: ReturnType<typeof createMockPrismaService>;
let mockTerminalService: ReturnType<typeof createMockTerminalService>;
let mockClient: AuthenticatedSocket;
beforeEach(() => {
mockAuthService = createMockAuthService();
mockPrismaService = createMockPrismaService();
mockTerminalService = createMockTerminalService();
mockClient = createMockSocket();
gateway = new TerminalGateway(
mockAuthService as unknown as AuthService,
mockPrismaService as unknown as PrismaService,
mockTerminalService as unknown as TerminalService
);
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
// ==========================================
// handleConnection (authentication)
// ==========================================
describe("handleConnection", () => {
it("should authenticate client and join workspace room on valid token", async () => {
mockAuthService.verifySession.mockResolvedValue({
user: { id: "user-123" },
});
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(mockClient);
expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token");
expect(mockClient.data.userId).toBe("user-123");
expect(mockClient.data.workspaceId).toBe("workspace-456");
expect(mockClient.join).toHaveBeenCalledWith("terminal:workspace-456");
});
it("should disconnect and emit error if no token provided", async () => {
const clientNoToken = createMockSocket("no-token");
clientNoToken.handshake = {
auth: {},
query: {},
headers: {},
} as typeof clientNoToken.handshake;
await gateway.handleConnection(clientNoToken);
expect(clientNoToken.disconnect).toHaveBeenCalled();
expect(clientNoToken.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("no token") })
);
});
it("should disconnect and emit error if token is invalid", async () => {
mockAuthService.verifySession.mockResolvedValue(null);
await gateway.handleConnection(mockClient);
expect(mockClient.disconnect).toHaveBeenCalled();
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("invalid") })
);
});
it("should disconnect and emit error if no workspace access", async () => {
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue(null);
await gateway.handleConnection(mockClient);
expect(mockClient.disconnect).toHaveBeenCalled();
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("workspace") })
);
});
it("should disconnect and emit error if auth throws", async () => {
mockAuthService.verifySession.mockRejectedValue(new Error("Auth service down"));
await gateway.handleConnection(mockClient);
expect(mockClient.disconnect).toHaveBeenCalled();
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.any(String) })
);
});
it("should extract token from handshake.query as fallback", async () => {
const clientQueryToken = createMockSocket("query-token-client");
clientQueryToken.handshake = {
auth: {},
query: { token: "query-token" },
headers: {},
} as typeof clientQueryToken.handshake;
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(clientQueryToken);
expect(mockAuthService.verifySession).toHaveBeenCalledWith("query-token");
});
it("should extract token from Authorization header as last fallback", async () => {
const clientHeaderToken = createMockSocket("header-token-client");
clientHeaderToken.handshake = {
auth: {},
query: {},
headers: { authorization: "Bearer header-token" },
} as typeof clientHeaderToken.handshake;
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(clientHeaderToken);
expect(mockAuthService.verifySession).toHaveBeenCalledWith("header-token");
});
});
// ==========================================
// handleDisconnect
// ==========================================
describe("handleDisconnect", () => {
it("should close all workspace sessions on disconnect", async () => {
await gateway.handleConnection(mockClient);
vi.clearAllMocks();
gateway.handleDisconnect(mockClient);
expect(mockTerminalService.closeWorkspaceSessions).toHaveBeenCalledWith("workspace-456");
});
it("should not throw for unauthenticated client disconnect", () => {
const unauthClient = createMockSocket("unauth-disconnect");
expect(() => gateway.handleDisconnect(unauthClient)).not.toThrow();
expect(mockTerminalService.closeWorkspaceSessions).not.toHaveBeenCalled();
});
});
// ==========================================
// handleCreate (terminal:create)
// ==========================================
describe("handleCreate", () => {
beforeEach(async () => {
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(mockClient);
vi.clearAllMocks();
});
it("should create a PTY session and emit terminal:created", async () => {
mockTerminalService.createSession.mockReturnValue({
sessionId: "new-session-id",
cols: 80,
rows: 24,
});
await gateway.handleCreate(mockClient, {});
expect(mockTerminalService.createSession).toHaveBeenCalled();
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:created",
expect.objectContaining({ sessionId: "new-session-id" })
);
});
it("should pass cols, rows, cwd, name to service", async () => {
await gateway.handleCreate(mockClient, {
cols: 132,
rows: 50,
cwd: "/home/user",
name: "my-shell",
});
expect(mockTerminalService.createSession).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ cols: 132, rows: 50, cwd: "/home/user", name: "my-shell" })
);
});
it("should emit terminal:error if not authenticated", async () => {
const unauthClient = createMockSocket("unauth");
await gateway.handleCreate(unauthClient, {});
expect(unauthClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("authenticated") })
);
});
it("should emit terminal:error if service throws (session limit)", async () => {
mockTerminalService.createSession.mockImplementation(() => {
throw new Error("Workspace has reached the maximum of 10 concurrent terminal sessions");
});
await gateway.handleCreate(mockClient, {});
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("maximum") })
);
});
it("should emit terminal:error for invalid payload (negative cols)", async () => {
await gateway.handleCreate(mockClient, { cols: -1 });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
);
});
});
// ==========================================
// handleInput (terminal:input)
// ==========================================
describe("handleInput", () => {
beforeEach(async () => {
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(mockClient);
vi.clearAllMocks();
});
it("should write data to the PTY session", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
await gateway.handleInput(mockClient, { sessionId: "sess-1", data: "ls\n" });
expect(mockTerminalService.writeToSession).toHaveBeenCalledWith("sess-1", "ls\n");
});
it("should emit terminal:error if session does not belong to workspace", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
await gateway.handleInput(mockClient, { sessionId: "alien-sess", data: "data" });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("not found") })
);
expect(mockTerminalService.writeToSession).not.toHaveBeenCalled();
});
it("should emit terminal:error if not authenticated", async () => {
const unauthClient = createMockSocket("unauth");
await gateway.handleInput(unauthClient, { sessionId: "sess-1", data: "x" });
expect(unauthClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("authenticated") })
);
});
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
await gateway.handleInput(mockClient, { data: "some input" });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
);
});
});
// ==========================================
// handleResize (terminal:resize)
// ==========================================
describe("handleResize", () => {
beforeEach(async () => {
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(mockClient);
vi.clearAllMocks();
});
it("should resize the PTY session", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 120, rows: 40 });
expect(mockTerminalService.resizeSession).toHaveBeenCalledWith("sess-1", 120, 40);
});
it("should emit terminal:error if session does not belong to workspace", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
await gateway.handleResize(mockClient, { sessionId: "alien-sess", cols: 80, rows: 24 });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("not found") })
);
});
it("should emit terminal:error for invalid payload (cols too large)", async () => {
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 9999, rows: 24 });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
);
});
});
// ==========================================
// handleClose (terminal:close)
// ==========================================
describe("handleClose", () => {
beforeEach(async () => {
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(mockClient);
vi.clearAllMocks();
});
it("should close an existing PTY session", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
mockTerminalService.closeSession.mockReturnValue(true);
await gateway.handleClose(mockClient, { sessionId: "sess-1" });
expect(mockTerminalService.closeSession).toHaveBeenCalledWith("sess-1");
});
it("should emit terminal:error if session does not belong to workspace", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
await gateway.handleClose(mockClient, { sessionId: "alien-sess" });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("not found") })
);
});
it("should emit terminal:error if closeSession returns false (session gone)", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
mockTerminalService.closeSession.mockReturnValue(false);
await gateway.handleClose(mockClient, { sessionId: "gone-sess" });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("not found") })
);
});
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
await gateway.handleClose(mockClient, {});
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
);
});
});
});

View File

@@ -0,0 +1,423 @@
/**
* TerminalGateway
*
* WebSocket gateway for real-time PTY terminal sessions.
* Uses the `/terminal` namespace to keep terminal traffic separate
* from the main WebSocket gateway.
*
* Protocol:
* 1. Client connects with auth token in handshake
* 2. Client emits `terminal:create` to spawn a new PTY session
* 3. Server emits `terminal:created` with { sessionId }
* 4. Client emits `terminal:input` with { sessionId, data } to send keystrokes
* 5. Server emits `terminal:output` with { sessionId, data } for stdout/stderr
* 6. Client emits `terminal:resize` with { sessionId, cols, rows } on window resize
* 7. Client emits `terminal:close` with { sessionId } to terminate the PTY
* 8. Server emits `terminal:exit` with { sessionId, exitCode, signal } on PTY exit
*
* Authentication:
* - Same pattern as websocket.gateway.ts and speech.gateway.ts
* - Token extracted from handshake.auth.token / query.token / Authorization header
*
* Workspace isolation:
* - Clients join room `terminal:{workspaceId}` on connect
* - Sessions are scoped to workspace; cross-workspace access is denied
*/
import {
WebSocketGateway as WSGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
} from "@nestjs/websockets";
import { Logger } from "@nestjs/common";
import { Server, Socket } from "socket.io";
import { AuthService } from "../auth/auth.service";
import { PrismaService } from "../prisma/prisma.service";
import { TerminalService } from "./terminal.service";
import {
CreateTerminalDto,
TerminalInputDto,
TerminalResizeDto,
CloseTerminalDto,
} from "./terminal.dto";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
// ==========================================
// Types
// ==========================================
interface AuthenticatedSocket extends Socket {
data: {
userId?: string;
workspaceId?: string;
};
}
// ==========================================
// Gateway
// ==========================================
@WSGateway({
namespace: "/terminal",
cors: {
origin: process.env.WEB_URL ?? "http://localhost:3000",
credentials: true,
},
})
export class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(TerminalGateway.name);
private readonly CONNECTION_TIMEOUT_MS = 5000;
constructor(
private readonly authService: AuthService,
private readonly prisma: PrismaService,
private readonly terminalService: TerminalService
) {}
// ==========================================
// Connection lifecycle
// ==========================================
/**
* Authenticate client on connection using handshake token.
* Validates workspace membership and joins the workspace-scoped room.
*/
async handleConnection(client: Socket): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const timeoutId = setTimeout(() => {
if (!authenticatedClient.data.userId) {
this.logger.warn(
`Terminal client ${authenticatedClient.id} timed out during authentication`
);
authenticatedClient.emit("terminal:error", {
message: "Authentication timed out.",
});
authenticatedClient.disconnect();
}
}, this.CONNECTION_TIMEOUT_MS);
try {
const token = this.extractTokenFromHandshake(authenticatedClient);
if (!token) {
this.logger.warn(`Terminal client ${authenticatedClient.id} connected without token`);
authenticatedClient.emit("terminal:error", {
message: "Authentication failed: no token provided.",
});
authenticatedClient.disconnect();
clearTimeout(timeoutId);
return;
}
const sessionData = await this.authService.verifySession(token);
if (!sessionData) {
this.logger.warn(`Terminal client ${authenticatedClient.id} has invalid token`);
authenticatedClient.emit("terminal:error", {
message: "Authentication failed: invalid or expired token.",
});
authenticatedClient.disconnect();
clearTimeout(timeoutId);
return;
}
const user = sessionData.user as { id: string };
const userId = user.id;
const workspaceMembership = await this.prisma.workspaceMember.findFirst({
where: { userId },
select: { workspaceId: true, userId: true, role: true },
});
if (!workspaceMembership) {
this.logger.warn(`Terminal user ${userId} has no workspace access`);
authenticatedClient.emit("terminal:error", {
message: "Authentication failed: no workspace access.",
});
authenticatedClient.disconnect();
clearTimeout(timeoutId);
return;
}
authenticatedClient.data.userId = userId;
authenticatedClient.data.workspaceId = workspaceMembership.workspaceId;
// Join workspace-scoped terminal room
const room = this.getWorkspaceRoom(workspaceMembership.workspaceId);
await authenticatedClient.join(room);
clearTimeout(timeoutId);
this.logger.log(
`Terminal client ${authenticatedClient.id} connected (user: ${userId}, workspace: ${workspaceMembership.workspaceId})`
);
} catch (error) {
clearTimeout(timeoutId);
this.logger.error(
`Authentication failed for terminal client ${authenticatedClient.id}:`,
error instanceof Error ? error.message : "Unknown error"
);
authenticatedClient.emit("terminal:error", {
message: "Authentication failed: an unexpected error occurred.",
});
authenticatedClient.disconnect();
}
}
/**
* Clean up all PTY sessions for this client's workspace on disconnect.
*/
handleDisconnect(client: Socket): void {
const authenticatedClient = client as AuthenticatedSocket;
const { workspaceId, userId } = authenticatedClient.data;
if (workspaceId) {
this.terminalService.closeWorkspaceSessions(workspaceId);
const room = this.getWorkspaceRoom(workspaceId);
void authenticatedClient.leave(room);
this.logger.log(
`Terminal client ${authenticatedClient.id} disconnected (user: ${userId ?? "unknown"}, workspace: ${workspaceId})`
);
} else {
this.logger.debug(`Terminal client ${authenticatedClient.id} disconnected (unauthenticated)`);
}
}
// ==========================================
// Terminal events
// ==========================================
/**
* Spawn a new PTY session for the connected client.
*
* Emits `terminal:created` with { sessionId, name, cols, rows } on success.
* Emits `terminal:error` on failure.
*/
@SubscribeMessage("terminal:create")
async handleCreate(client: Socket, payload: unknown): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const { userId, workspaceId } = authenticatedClient.data;
if (!userId || !workspaceId) {
authenticatedClient.emit("terminal:error", {
message: "Not authenticated. Connect with a valid token.",
});
return;
}
// Validate DTO
const dto = plainToInstance(CreateTerminalDto, payload ?? {});
const errors = await validate(dto);
if (errors.length > 0) {
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
authenticatedClient.emit("terminal:error", {
message: `Invalid payload: ${messages}`,
});
return;
}
try {
const result = this.terminalService.createSession(authenticatedClient, {
workspaceId,
socketId: authenticatedClient.id,
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.cols !== undefined ? { cols: dto.cols } : {}),
...(dto.rows !== undefined ? { rows: dto.rows } : {}),
...(dto.cwd !== undefined ? { cwd: dto.cwd } : {}),
});
authenticatedClient.emit("terminal:created", {
sessionId: result.sessionId,
name: result.name,
cols: result.cols,
rows: result.rows,
});
this.logger.log(
`Terminal session ${result.sessionId} created for client ${authenticatedClient.id} (workspace: ${workspaceId})`
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(
`Failed to create terminal session for client ${authenticatedClient.id}: ${message}`
);
authenticatedClient.emit("terminal:error", { message });
}
}
/**
* Write input data to an existing PTY session.
*
* Emits `terminal:error` if the session is not found or unauthorized.
*/
@SubscribeMessage("terminal:input")
async handleInput(client: Socket, payload: unknown): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const { userId, workspaceId } = authenticatedClient.data;
if (!userId || !workspaceId) {
authenticatedClient.emit("terminal:error", {
message: "Not authenticated. Connect with a valid token.",
});
return;
}
const dto = plainToInstance(TerminalInputDto, payload ?? {});
const errors = await validate(dto);
if (errors.length > 0) {
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
authenticatedClient.emit("terminal:error", {
message: `Invalid payload: ${messages}`,
});
return;
}
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
authenticatedClient.emit("terminal:error", {
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
});
return;
}
try {
this.terminalService.writeToSession(dto.sessionId, dto.data);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to write to terminal session ${dto.sessionId}: ${message}`);
authenticatedClient.emit("terminal:error", { message });
}
}
/**
* Resize an existing PTY session.
*
* Emits `terminal:error` if the session is not found or unauthorized.
*/
@SubscribeMessage("terminal:resize")
async handleResize(client: Socket, payload: unknown): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const { userId, workspaceId } = authenticatedClient.data;
if (!userId || !workspaceId) {
authenticatedClient.emit("terminal:error", {
message: "Not authenticated. Connect with a valid token.",
});
return;
}
const dto = plainToInstance(TerminalResizeDto, payload ?? {});
const errors = await validate(dto);
if (errors.length > 0) {
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
authenticatedClient.emit("terminal:error", {
message: `Invalid payload: ${messages}`,
});
return;
}
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
authenticatedClient.emit("terminal:error", {
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
});
return;
}
try {
this.terminalService.resizeSession(dto.sessionId, dto.cols, dto.rows);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to resize terminal session ${dto.sessionId}: ${message}`);
authenticatedClient.emit("terminal:error", { message });
}
}
/**
* Kill and close an existing PTY session.
*
* Emits `terminal:error` if the session is not found or unauthorized.
*/
@SubscribeMessage("terminal:close")
async handleClose(client: Socket, payload: unknown): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const { userId, workspaceId } = authenticatedClient.data;
if (!userId || !workspaceId) {
authenticatedClient.emit("terminal:error", {
message: "Not authenticated. Connect with a valid token.",
});
return;
}
const dto = plainToInstance(CloseTerminalDto, payload ?? {});
const errors = await validate(dto);
if (errors.length > 0) {
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
authenticatedClient.emit("terminal:error", {
message: `Invalid payload: ${messages}`,
});
return;
}
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
authenticatedClient.emit("terminal:error", {
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
});
return;
}
const closed = this.terminalService.closeSession(dto.sessionId);
if (!closed) {
authenticatedClient.emit("terminal:error", {
message: `Terminal session ${dto.sessionId} not found.`,
});
return;
}
this.logger.log(`Terminal session ${dto.sessionId} closed by client ${authenticatedClient.id}`);
}
// ==========================================
// Private helpers
// ==========================================
/**
* Extract authentication token from Socket.IO handshake.
* Checks auth.token, query.token, and Authorization header (in that order).
*/
private extractTokenFromHandshake(client: Socket): string | undefined {
const authToken = client.handshake.auth.token as unknown;
if (typeof authToken === "string" && authToken.length > 0) {
return authToken;
}
const queryToken = client.handshake.query.token as unknown;
if (typeof queryToken === "string" && queryToken.length > 0) {
return queryToken;
}
const authHeader = client.handshake.headers.authorization as unknown;
if (typeof authHeader === "string") {
const parts = authHeader.split(" ");
const [type, token] = parts;
if (type === "Bearer" && token) {
return token;
}
}
return undefined;
}
/**
* Get the workspace-scoped room name for the terminal namespace.
*/
private getWorkspaceRoom(workspaceId: string): string {
return `terminal:${workspaceId}`;
}
}

View File

@@ -0,0 +1,31 @@
/**
* TerminalModule
*
* NestJS module for WebSocket-based terminal sessions via node-pty.
*
* Imports:
* - AuthModule for WebSocket authentication (verifySession)
* - PrismaModule for workspace membership queries and session persistence
*
* Providers:
* - TerminalService: manages PTY session lifecycle (in-memory)
* - TerminalSessionService: persists session records to the database
* - TerminalGateway: WebSocket gateway on /terminal namespace
*
* The module does not export providers; terminal sessions are
* self-contained within this module.
*/
import { Module } from "@nestjs/common";
import { TerminalGateway } from "./terminal.gateway";
import { TerminalService } from "./terminal.service";
import { TerminalSessionService } from "./terminal-session.service";
import { AuthModule } from "../auth/auth.module";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
imports: [AuthModule, PrismaModule],
providers: [TerminalGateway, TerminalService, TerminalSessionService],
exports: [TerminalSessionService],
})
export class TerminalModule {}

View File

@@ -0,0 +1,337 @@
/**
* TerminalService Tests
*
* Unit tests for PTY session management: create, write, resize, close,
* workspace cleanup, and access control.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import type { Socket } from "socket.io";
import { TerminalService, MAX_SESSIONS_PER_WORKSPACE } from "./terminal.service";
// ==========================================
// Mocks
// ==========================================
// Mock node-pty before importing service
const mockPtyProcess = {
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
pid: 12345,
};
vi.mock("node-pty", () => ({
spawn: vi.fn(() => mockPtyProcess),
}));
function createMockSocket(id = "socket-1"): Socket {
return {
id,
emit: vi.fn(),
join: vi.fn(),
leave: vi.fn(),
disconnect: vi.fn(),
data: {},
} as unknown as Socket;
}
// ==========================================
// Tests
// ==========================================
describe("TerminalService", () => {
let service: TerminalService;
let mockSocket: Socket;
beforeEach(() => {
vi.clearAllMocks();
// Reset mock implementations
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
mockPtyProcess.onExit.mockImplementation(
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
);
service = new TerminalService();
mockSocket = createMockSocket();
});
afterEach(() => {
vi.clearAllMocks();
});
// ==========================================
// createSession
// ==========================================
describe("createSession", () => {
it("should create a PTY session and return sessionId", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(result.sessionId).toBeDefined();
expect(typeof result.sessionId).toBe("string");
expect(result.cols).toBe(80);
expect(result.rows).toBe(24);
});
it("should use provided cols and rows", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
cols: 120,
rows: 40,
});
expect(result.cols).toBe(120);
expect(result.rows).toBe(40);
});
it("should return the provided session name", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
name: "my-terminal",
});
expect(result.name).toBe("my-terminal");
});
it("should wire PTY onData to emit terminal:output", () => {
let dataCallback: ((data: string) => void) | undefined;
mockPtyProcess.onData.mockImplementation((cb: (data: string) => void) => {
dataCallback = cb;
});
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(dataCallback).toBeDefined();
dataCallback!("hello world");
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:output", {
sessionId: result.sessionId,
data: "hello world",
});
});
it("should wire PTY onExit to emit terminal:exit and cleanup", () => {
let exitCallback: ((e: { exitCode: number; signal?: number }) => void) | undefined;
mockPtyProcess.onExit.mockImplementation(
(cb: (e: { exitCode: number; signal?: number }) => void) => {
exitCallback = cb;
}
);
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(exitCallback).toBeDefined();
exitCallback!({ exitCode: 0 });
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:exit", {
sessionId: result.sessionId,
exitCode: 0,
signal: undefined,
});
// Session should be cleaned up
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false);
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
});
it("should throw when workspace session limit is reached", () => {
const limit = MAX_SESSIONS_PER_WORKSPACE;
for (let i = 0; i < limit; i++) {
service.createSession(createMockSocket(`socket-${String(i)}`), {
workspaceId: "ws-limit",
socketId: `socket-${String(i)}`,
});
}
expect(() =>
service.createSession(createMockSocket("socket-overflow"), {
workspaceId: "ws-limit",
socketId: "socket-overflow",
})
).toThrow(/maximum/i);
});
it("should allow sessions in different workspaces independently", () => {
service.createSession(mockSocket, { workspaceId: "ws-a", socketId: "s1" });
service.createSession(createMockSocket("s2"), { workspaceId: "ws-b", socketId: "s2" });
expect(service.getWorkspaceSessionCount("ws-a")).toBe(1);
expect(service.getWorkspaceSessionCount("ws-b")).toBe(1);
});
});
// ==========================================
// writeToSession
// ==========================================
describe("writeToSession", () => {
it("should write data to PTY", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
service.writeToSession(result.sessionId, "ls -la\n");
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n");
});
it("should throw for unknown sessionId", () => {
expect(() => service.writeToSession("nonexistent-id", "data")).toThrow(/not found/i);
});
});
// ==========================================
// resizeSession
// ==========================================
describe("resizeSession", () => {
it("should resize PTY dimensions", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
service.resizeSession(result.sessionId, 132, 50);
expect(mockPtyProcess.resize).toHaveBeenCalledWith(132, 50);
});
it("should throw for unknown sessionId", () => {
expect(() => service.resizeSession("nonexistent-id", 80, 24)).toThrow(/not found/i);
});
});
// ==========================================
// closeSession
// ==========================================
describe("closeSession", () => {
it("should kill PTY and return true for existing session", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
const closed = service.closeSession(result.sessionId);
expect(closed).toBe(true);
expect(mockPtyProcess.kill).toHaveBeenCalled();
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false);
});
it("should return false for nonexistent sessionId", () => {
const closed = service.closeSession("does-not-exist");
expect(closed).toBe(false);
});
it("should clean up workspace tracking after close", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(service.getWorkspaceSessionCount("ws-1")).toBe(1);
service.closeSession(result.sessionId);
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
});
it("should not throw if PTY kill throws", () => {
mockPtyProcess.kill.mockImplementationOnce(() => {
throw new Error("PTY already dead");
});
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(() => service.closeSession(result.sessionId)).not.toThrow();
});
});
// ==========================================
// closeWorkspaceSessions
// ==========================================
describe("closeWorkspaceSessions", () => {
it("should kill all sessions for a workspace", () => {
service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" });
service.createSession(createMockSocket("s2"), { workspaceId: "ws-1", socketId: "s2" });
expect(service.getWorkspaceSessionCount("ws-1")).toBe(2);
service.closeWorkspaceSessions("ws-1");
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
expect(mockPtyProcess.kill).toHaveBeenCalledTimes(2);
});
it("should not affect sessions in other workspaces", () => {
service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" });
service.createSession(createMockSocket("s2"), { workspaceId: "ws-2", socketId: "s2" });
service.closeWorkspaceSessions("ws-1");
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
expect(service.getWorkspaceSessionCount("ws-2")).toBe(1);
});
it("should not throw for workspaces with no sessions", () => {
expect(() => service.closeWorkspaceSessions("ws-nonexistent")).not.toThrow();
});
});
// ==========================================
// sessionBelongsToWorkspace
// ==========================================
describe("sessionBelongsToWorkspace", () => {
it("should return true for a session belonging to the workspace", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(true);
});
it("should return false for a session in a different workspace", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-2")).toBe(false);
});
it("should return false for a nonexistent sessionId", () => {
expect(service.sessionBelongsToWorkspace("no-such-id", "ws-1")).toBe(false);
});
});
// ==========================================
// getWorkspaceSessionCount
// ==========================================
describe("getWorkspaceSessionCount", () => {
it("should return 0 for workspace with no sessions", () => {
expect(service.getWorkspaceSessionCount("empty-ws")).toBe(0);
});
it("should track session count accurately", () => {
service.createSession(mockSocket, { workspaceId: "ws-count", socketId: "s1" });
expect(service.getWorkspaceSessionCount("ws-count")).toBe(1);
service.createSession(createMockSocket("s2"), { workspaceId: "ws-count", socketId: "s2" });
expect(service.getWorkspaceSessionCount("ws-count")).toBe(2);
});
});
});

View File

@@ -0,0 +1,251 @@
/**
* TerminalService
*
* Manages PTY (pseudo-terminal) sessions for workspace users.
* Spawns real shell processes via node-pty, streams I/O to connected sockets,
* and enforces per-workspace session limits.
*
* Session lifecycle:
* - createSession: spawn a new PTY, wire onData/onExit, return sessionId
* - writeToSession: send input data to PTY stdin
* - resizeSession: resize PTY dimensions (cols x rows)
* - closeSession: kill PTY process, emit terminal:exit, cleanup
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
*/
import { Injectable, Logger } from "@nestjs/common";
import * as pty from "node-pty";
import type { Socket } from "socket.io";
import { randomUUID } from "node:crypto";
/** Maximum concurrent PTY sessions per workspace */
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
10
);
/** Default PTY dimensions */
const DEFAULT_COLS = 80;
const DEFAULT_ROWS = 24;
export interface TerminalSession {
sessionId: string;
workspaceId: string;
pty: pty.IPty;
name?: string;
createdAt: Date;
}
export interface CreateSessionOptions {
name?: string;
cols?: number;
rows?: number;
cwd?: string;
workspaceId: string;
socketId: string;
}
export interface SessionCreatedResult {
sessionId: string;
name?: string;
cols: number;
rows: number;
}
@Injectable()
export class TerminalService {
private readonly logger = new Logger(TerminalService.name);
/**
* Map of sessionId -> TerminalSession
*/
private readonly sessions = new Map<string, TerminalSession>();
/**
* Map of workspaceId -> Set<sessionId> for fast per-workspace lookups
*/
private readonly workspaceSessions = new Map<string, Set<string>>();
/**
* Create a new PTY session for the given workspace and socket.
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
*
* @throws Error if workspace session limit is exceeded
*/
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
const { workspaceId, name, cwd, socketId } = options;
const cols = options.cols ?? DEFAULT_COLS;
const rows = options.rows ?? DEFAULT_ROWS;
// Enforce per-workspace session limit
const workspaceSessionIds = this.workspaceSessions.get(workspaceId) ?? new Set<string>();
if (workspaceSessionIds.size >= MAX_SESSIONS_PER_WORKSPACE) {
throw new Error(
`Workspace ${workspaceId} has reached the maximum of ${String(MAX_SESSIONS_PER_WORKSPACE)} concurrent terminal sessions`
);
}
const sessionId = randomUUID();
const shell = process.env.SHELL ?? "/bin/bash";
this.logger.log(
`Spawning PTY session ${sessionId} for workspace ${workspaceId} (socket: ${socketId}, shell: ${shell}, ${String(cols)}x${String(rows)})`
);
const ptyProcess = pty.spawn(shell, [], {
name: "xterm-256color",
cols,
rows,
cwd: cwd ?? process.cwd(),
env: process.env as Record<string, string>,
});
const session: TerminalSession = {
sessionId,
workspaceId,
pty: ptyProcess,
...(name !== undefined ? { name } : {}),
createdAt: new Date(),
};
this.sessions.set(sessionId, session);
// Track by workspace
if (!this.workspaceSessions.has(workspaceId)) {
this.workspaceSessions.set(workspaceId, new Set());
}
const wsSet = this.workspaceSessions.get(workspaceId);
if (wsSet) {
wsSet.add(sessionId);
}
// Wire PTY stdout/stderr -> terminal:output
ptyProcess.onData((data: string) => {
socket.emit("terminal:output", { sessionId, data });
});
// Wire PTY exit -> terminal:exit, cleanup
ptyProcess.onExit(({ exitCode, signal }) => {
this.logger.log(
`PTY session ${sessionId} exited (exitCode: ${String(exitCode)}, signal: ${String(signal ?? "none")})`
);
socket.emit("terminal:exit", { sessionId, exitCode, signal });
this.cleanupSession(sessionId, workspaceId);
});
return { sessionId, ...(name !== undefined ? { name } : {}), cols, rows };
}
/**
* Write input data to a PTY session's stdin.
*
* @throws Error if session not found
*/
writeToSession(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Terminal session ${sessionId} not found`);
}
session.pty.write(data);
}
/**
* Resize a PTY session's terminal dimensions.
*
* @throws Error if session not found
*/
resizeSession(sessionId: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Terminal session ${sessionId} not found`);
}
session.pty.resize(cols, rows);
this.logger.debug(`Resized PTY session ${sessionId} to ${String(cols)}x${String(rows)}`);
}
/**
* Kill and clean up a specific PTY session.
* Returns true if the session existed, false if it was already gone.
*/
closeSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
return false;
}
this.logger.log(`Closing PTY session ${sessionId} for workspace ${session.workspaceId}`);
try {
session.pty.kill();
} catch (error) {
this.logger.warn(
`Error killing PTY session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`
);
}
this.cleanupSession(sessionId, session.workspaceId);
return true;
}
/**
* Close all PTY sessions for a workspace (called on client disconnect).
*/
closeWorkspaceSessions(workspaceId: string): void {
const sessionIds = this.workspaceSessions.get(workspaceId);
if (!sessionIds || sessionIds.size === 0) {
return;
}
this.logger.log(
`Closing ${String(sessionIds.size)} PTY session(s) for workspace ${workspaceId} (disconnect)`
);
// Copy to array to avoid mutation during iteration
const ids = Array.from(sessionIds);
for (const sessionId of ids) {
const session = this.sessions.get(sessionId);
if (session) {
try {
session.pty.kill();
} catch (error) {
this.logger.warn(
`Error killing PTY session ${sessionId} on disconnect: ${error instanceof Error ? error.message : String(error)}`
);
}
this.cleanupSession(sessionId, workspaceId);
}
}
}
/**
* Get the number of active sessions for a workspace.
*/
getWorkspaceSessionCount(workspaceId: string): number {
return this.workspaceSessions.get(workspaceId)?.size ?? 0;
}
/**
* Check if a session belongs to a given workspace.
* Used for access control in the gateway.
*/
sessionBelongsToWorkspace(sessionId: string, workspaceId: string): boolean {
const session = this.sessions.get(sessionId);
return session?.workspaceId === workspaceId;
}
/**
* Internal cleanup: remove session from tracking maps.
* Does NOT kill the PTY (caller is responsible).
*/
private cleanupSession(sessionId: string, workspaceId: string): void {
this.sessions.delete(sessionId);
const workspaceSessionIds = this.workspaceSessions.get(workspaceId);
if (workspaceSessionIds) {
workspaceSessionIds.delete(sessionId);
if (workspaceSessionIds.size === 0) {
this.workspaceSessions.delete(workspaceId);
}
}
}
}

View File

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

View File

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

View File

@@ -128,12 +128,31 @@ function LoginPageContent(): ReactElement {
setError(null); setError(null);
const callbackURL = const callbackURL =
typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/"; typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/";
signIn.oauth2({ providerId, callbackURL }).catch((err: unknown) => { signIn
const message = err instanceof Error ? err.message : String(err); .oauth2({ providerId, callbackURL })
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message); .then((result) => {
setError("Unable to connect to the sign-in provider. Please try again in a moment."); // BetterAuth returns Data | Error union — check for error or missing redirect URL
setOauthLoading(null); const hasError = "error" in result && result.error;
}); const hasUrl = "data" in result && result.data?.url;
if (hasError || !hasUrl) {
const errObj = hasError ? result.error : null;
const message =
errObj && typeof errObj === "object" && "message" in errObj
? String(errObj.message)
: "no redirect URL";
console.error(`[Auth] OAuth sign-in failed for ${providerId}:`, message);
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
setOauthLoading(null);
}
// If data.url exists, BetterAuth's client will redirect the browser automatically.
// No need to reset loading — the page is navigating away.
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
setOauthLoading(null);
});
}, []); }, []);
const handleCredentialsLogin = useCallback( const handleCredentialsLogin = useCallback(

View File

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

View File

@@ -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) {
}, []);
async function loadEvents(): Promise<void> {
setIsLoading(true);
setError(null);
try {
// TODO: Replace with real API call when backend is ready
// const data = await fetchEvents();
await new Promise((resolve) => setTimeout(resolve, 300));
setEvents(mockEvents);
} catch (err) {
setError(
err instanceof Error
? err.message
: "We had trouble loading your calendar. Please try again when you're ready."
);
} finally {
setIsLoading(false); setIsLoading(false);
return;
} }
const wsId = workspaceId;
let cancelled = false;
setError(null);
setIsLoading(true);
async function loadEvents(): Promise<void> {
try {
const data = await fetchEvents(wsId);
if (!cancelled) {
setEvents(data);
}
} catch (err: unknown) {
console.error("[Calendar] Failed to fetch events:", err);
if (!cancelled) {
setError(
err instanceof Error
? err.message
: "We had trouble loading your calendar. Please try again when you're ready."
);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
void loadEvents();
return (): void => {
cancelled = true;
};
}, [workspaceId]);
function handleRetry(): void {
if (!workspaceId) return;
const wsId = workspaceId;
setError(null);
setIsLoading(true);
fetchEvents(wsId)
.then((data) => {
setEvents(data);
})
.catch((err: unknown) => {
console.error("[Calendar] Retry failed:", err);
setError(
err instanceof Error
? err.message
: "We had trouble loading your calendar. Please try again when you're ready."
);
})
.finally(() => {
setIsLoading(false);
});
}
if (isLoading) {
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
Calendar
</h1>
<p style={{ color: "var(--text-muted)" }} className="mt-2">
View your schedule at a glance
</p>
</div>
<div className="flex justify-center py-16">
<MosaicSpinner label="Loading calendar..." />
</div>
</main>
);
}
if (error !== null) {
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
Calendar
</h1>
<p style={{ color: "var(--text-muted)" }} className="mt-2">
View your schedule at a glance
</p>
</div>
<div
className="rounded-lg p-6 text-center"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<p style={{ color: "var(--danger)" }}>{error}</p>
<button
onClick={handleRetry}
className="mt-4 rounded-md px-4 py-2 text-sm font-medium transition-colors"
style={{
background: "var(--accent)",
color: "var(--surface)",
}}
>
Try again
</button>
</div>
</main>
);
} }
return ( 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 >
</button> <p className="text-lg" style={{ color: "var(--text-muted)" }}>
No events scheduled
</p>
<p className="text-sm mt-2" style={{ color: "var(--text-muted)" }}>
Your calendar is clear
</p>
</div> </div>
) : ( ) : (
<Calendar events={events} isLoading={isLoading} /> <Calendar events={events} isLoading={false} />
)} )}
</main> </main>
); );

File diff suppressed because it is too large Load Diff

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

View File

@@ -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);
}
})
.catch((err: unknown) => {
console.error("Failed to load tags:", err);
});
// Filter by tag return (): void => {
if (selectedTag !== "all") { cancelled = true;
filtered = filtered.filter((entry) => };
entry.tags.some((tag: { slug: string }) => tag.slug === selectedTag) }, []);
);
}
// Filter by search query // Load entries when filters/sort/page change
if (searchQuery.trim()) { const loadEntries = useCallback(async (): Promise<void> => {
const query = searchQuery.toLowerCase(); setIsLoading(true);
filtered = filtered.filter( setError(null);
(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 try {
filtered.sort((a, b) => { const filters: Record<string, unknown> = {
let comparison = 0; page: currentPage,
limit: itemsPerPage,
sortBy,
sortOrder,
};
if (sortBy === "title") { if (selectedStatus !== "all") {
comparison = a.title.localeCompare(b.title); filters.status = selectedStatus;
} else if (sortBy === "createdAt") { }
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); if (selectedTag !== "all") {
} else { filters.tag = selectedTag;
// updatedAt }
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); if (searchQuery.trim()) {
filters.search = searchQuery.trim();
} }
return sortOrder === "asc" ? comparison : -comparison; const response: EntriesResponse = await fetchEntries(
}); filters as Parameters<typeof fetchEntries>[0]
);
setEntries(response.data);
setTotalEntries(response.meta?.total ?? response.data.length);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load entries");
} finally {
setIsLoading(false);
}
}, [currentPage, itemsPerPage, sortBy, sortOrder, selectedStatus, selectedTag, searchQuery]);
return filtered; useEffect(() => {
}, [selectedStatus, selectedTag, searchQuery, sortBy, sortOrder]); void loadEntries();
}, [loadEntries]);
// Pagination const totalPages = Math.max(1, Math.ceil(totalEntries / itemsPerPage));
const totalPages = Math.ceil(filteredAndSortedEntries.length / itemsPerPage);
const paginatedEntries = filteredAndSortedEntries.slice(
(currentPage - 1) * itemsPerPage,
currentPage * 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}

View 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 &amp; 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)",
}}
>
&#9654;
</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>
);
}

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

View File

@@ -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(); expect(screen.getByTestId("widget-TasksWidget-default")).toBeInTheDocument();
expect(screen.getByTestId("widget-CalendarWidget-default")).toBeInTheDocument();
});
it("should create default layout when none exists", async (): Promise<void> => {
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(null);
vi.mocked(layoutsApi.createLayout).mockResolvedValue({
...mockExistingLayout,
layout: [{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2 }],
});
render(<DashboardPage />);
await waitFor((): void => {
expect(layoutsApi.createLayout).toHaveBeenCalledWith("ws-test-123", {
name: "Default",
isDefault: true,
layout: expect.arrayContaining([
expect.objectContaining({ i: "TasksWidget-default" }),
]) as WidgetPlacement[],
});
}); });
}); });
it("should have proper layout structure", (): void => { it("should show loading spinner initially", (): void => {
const { container } = render(<DashboardPage />); // Never-resolving promise to test loading state
const main = container.querySelector("main"); vi.mocked(layoutsApi.fetchDefaultLayout).mockReturnValue(
expect(main).toBeInTheDocument(); // eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving
new Promise(() => {})
);
render(<DashboardPage />);
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
}); });
it("should render the welcome subtitle", (): void => { it("should fall back to default layout on API error", async (): Promise<void> => {
vi.mocked(layoutsApi.fetchDefaultLayout).mockRejectedValue(new Error("Network error"));
render(<DashboardPage />); render(<DashboardPage />);
expect(screen.getByText(/Welcome back/)).toBeInTheDocument();
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");
}); });
}); });

View File

@@ -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 {
return ( const workspaceId = useWorkspaceId();
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> const [layout, setLayout] = useState<WidgetPlacement[]>(DEFAULT_LAYOUT);
<DashboardMetrics /> const [layoutId, setLayoutId] = useState<string | null>(null);
<div const [isEditing, setIsEditing] = useState(false);
style={{ const [isPickerOpen, setIsPickerOpen] = useState(false);
display: "grid", const [configWidgetId, setConfigWidgetId] = useState<string | null>(null);
gridTemplateColumns: "1fr 320px", const [isLoading, setIsLoading] = useState(true);
gap: 16,
}} // Debounce timer for auto-saving layout changes
> const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
<OrchestratorSessions /> // Load the user's default layout (or create one)
<QuickActions /> useEffect(() => {
</div> if (!workspaceId) {
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> setIsLoading(false);
<ActivityFeed /> return;
<TokenBudget /> }
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 (
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
<div className="flex flex-col items-center gap-2">
<div
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
/>
<span className="text-sm" style={{ color: "var(--muted)" }}>
Loading dashboard...
</span>
</div> </div>
</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> </div>
); );
} }

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

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

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

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

View File

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

View File

@@ -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) {
}, []);
async function loadTasks(): Promise<void> {
setIsLoading(true);
setError(null);
try {
// TODO: Replace with real API call when backend is ready
// const data = await fetchTasks();
await new Promise((resolve) => setTimeout(resolve, 300));
setTasks(mockTasks);
} catch (err) {
setError(
err instanceof Error
? err.message
: "We had trouble loading your tasks. Please try again when you're ready."
);
} finally {
setIsLoading(false); setIsLoading(false);
return;
} }
let cancelled = false;
setError(null);
setIsLoading(true);
async function loadTasks(): Promise<void> {
try {
const filters = workspaceId !== null ? { workspaceId } : {};
const data = await fetchTasks(filters);
if (!cancelled) {
setTasks(data);
}
} catch (err: unknown) {
console.error("[Tasks] Failed to fetch tasks:", err);
if (!cancelled) {
setError(
err instanceof Error
? err.message
: "We had trouble loading your tasks. Please try again when you're ready."
);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
void loadTasks();
return (): void => {
cancelled = true;
};
}, [workspaceId]);
function handleRetry(): void {
if (!workspaceId) return;
setError(null);
setIsLoading(true);
fetchTasks({ workspaceId })
.then((data) => {
setTasks(data);
})
.catch((err: unknown) => {
console.error("[Tasks] Retry failed:", err);
setError(
err instanceof Error
? err.message
: "We had trouble loading your tasks. Please try again when you're ready."
);
})
.finally(() => {
setIsLoading(false);
});
} }
return ( 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>
); );

File diff suppressed because it is too large Load Diff

View File

@@ -765,6 +765,84 @@ body::before {
animation: scaleIn 0.1s ease-out; animation: scaleIn 0.1s ease-out;
} }
/* Streaming cursor for real-time token rendering */
@keyframes streaming-cursor-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.streaming-cursor {
display: inline-block;
width: 2px;
height: 1em;
background-color: rgb(var(--accent-primary));
border-radius: 1px;
animation: streaming-cursor-blink 1s step-end infinite;
vertical-align: text-bottom;
margin-left: 1px;
}
/* -----------------------------------------------------------------------------
Dashboard Layout — Responsive Grids
----------------------------------------------------------------------------- */
.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
----------------------------------------------------------------------------- */ ----------------------------------------------------------------------------- */

View 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&apos;re looking for doesn&apos;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>
);
}

View File

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

View File

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

View File

@@ -64,10 +64,12 @@ function createMockUseChatReturn(
}, },
], ],
isLoading: false, isLoading: false,
isStreaming: false,
error: null, error: null,
conversationId: null, conversationId: null,
conversationTitle: null, conversationTitle: null,
sendMessage: vi.fn().mockResolvedValue(undefined), sendMessage: vi.fn().mockResolvedValue(undefined),
abortStream: vi.fn(),
loadConversation: vi.fn().mockResolvedValue(undefined), loadConversation: vi.fn().mockResolvedValue(undefined),
startNewConversation: vi.fn(), startNewConversation: vi.fn(),
setMessages: vi.fn(), setMessages: vi.fn(),

View File

@@ -59,14 +59,15 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
const { user, isLoading: authLoading } = useAuth(); const { user, isLoading: authLoading } = useAuth();
// Use the chat hook for state management
const { const {
messages, messages,
isLoading: isChatLoading, isLoading: isChatLoading,
isStreaming,
error, error,
conversationId, conversationId,
conversationTitle, conversationTitle,
sendMessage, sendMessage,
abortStream,
loadConversation, loadConversation,
startNewConversation, startNewConversation,
clearError, clearError,
@@ -75,15 +76,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
...(initialProjectId !== undefined && { projectId: initialProjectId }), ...(initialProjectId !== undefined && { projectId: initialProjectId }),
}); });
// Connect to WebSocket for real-time updates (when we have a user) const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
const { isConnected: isWsConnected } = useWebSocket(
user?.id ?? "", // Use user ID as workspace ID for now
"", // Token not needed since we use cookies
{
// Future: Add handlers for chat-related events
// onChatMessage: (msg) => { ... }
}
);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -91,7 +84,10 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
const quipTimerRef = useRef<NodeJS.Timeout | null>(null); const quipTimerRef = useRef<NodeJS.Timeout | null>(null);
const quipIntervalRef = useRef<NodeJS.Timeout | null>(null); const quipIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Expose methods to parent via ref // Identify the streaming message (last assistant message while streaming)
const streamingMessageId =
isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined;
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
loadConversation: async (cId: string): Promise<void> => { loadConversation: async (cId: string): Promise<void> => {
await loadConversation(cId); await loadConversation(cId);
@@ -110,7 +106,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
scrollToBottom(); scrollToBottom();
}, [messages, scrollToBottom]); }, [messages, scrollToBottom]);
// Notify parent of conversation changes
useEffect(() => { useEffect(() => {
if (conversationId && conversationTitle) { if (conversationId && conversationTitle) {
onConversationChange?.(conversationId, { onConversationChange?.(conversationId, {
@@ -125,7 +120,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
} }
}, [conversationId, conversationTitle, initialProjectId, onConversationChange]); }, [conversationId, conversationTitle, initialProjectId, onConversationChange]);
// Global keyboard shortcut: Ctrl+/ to focus input
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => { const handleKeyDown = (e: KeyboardEvent): void => {
if ((e.ctrlKey || e.metaKey) && e.key === "/") { if ((e.ctrlKey || e.metaKey) && e.key === "/") {
@@ -139,20 +133,17 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
}; };
}, []); }, []);
// Show loading quips // Show loading quips only during non-streaming load (initial fetch wait)
useEffect(() => { useEffect(() => {
if (isChatLoading) { if (isChatLoading && !isStreaming) {
// Show first quip after 3 seconds
quipTimerRef.current = setTimeout(() => { quipTimerRef.current = setTimeout(() => {
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null); setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
}, 3000); }, 3000);
// Change quip every 5 seconds
quipIntervalRef.current = setInterval(() => { quipIntervalRef.current = setInterval(() => {
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null); setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
}, 5000); }, 5000);
} else { } else {
// Clear timers when loading stops
if (quipTimerRef.current) { if (quipTimerRef.current) {
clearTimeout(quipTimerRef.current); clearTimeout(quipTimerRef.current);
quipTimerRef.current = null; quipTimerRef.current = null;
@@ -168,7 +159,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
if (quipTimerRef.current) clearTimeout(quipTimerRef.current); if (quipTimerRef.current) clearTimeout(quipTimerRef.current);
if (quipIntervalRef.current) clearInterval(quipIntervalRef.current); if (quipIntervalRef.current) clearInterval(quipIntervalRef.current);
}; };
}, [isChatLoading]); }, [isChatLoading, isStreaming]);
const handleSendMessage = useCallback( const handleSendMessage = useCallback(
async (content: string) => { async (content: string) => {
@@ -177,7 +168,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
[sendMessage] [sendMessage]
); );
// Show loading state while auth is loading
if (authLoading) { if (authLoading) {
return ( return (
<div <div
@@ -227,6 +217,8 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
<MessageList <MessageList
messages={messages as (Message & { thinking?: string })[]} messages={messages as (Message & { thinking?: string })[]}
isLoading={isChatLoading} isLoading={isChatLoading}
isStreaming={isStreaming}
{...(streamingMessageId != null ? { streamingMessageId } : {})}
loadingQuip={loadingQuip} loadingQuip={loadingQuip}
/> />
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
@@ -294,6 +286,8 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
onSend={handleSendMessage} onSend={handleSendMessage}
disabled={isChatLoading || !user} disabled={isChatLoading || !user}
inputRef={inputRef} inputRef={inputRef}
isStreaming={isStreaming}
onStopStreaming={abortStream}
/> />
</div> </div>
</div> </div>

View File

@@ -7,13 +7,20 @@ interface ChatInputProps {
onSend: (message: string) => void; onSend: (message: string) => void;
disabled?: boolean; disabled?: boolean;
inputRef?: RefObject<HTMLTextAreaElement | null>; inputRef?: RefObject<HTMLTextAreaElement | null>;
isStreaming?: boolean;
onStopStreaming?: () => void;
} }
export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React.JSX.Element { export function ChatInput({
onSend,
disabled,
inputRef,
isStreaming = false,
onStopStreaming,
}: ChatInputProps): React.JSX.Element {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [version, setVersion] = useState<string | null>(null); const [version, setVersion] = useState<string | null>(null);
// Fetch version from static version.json (generated at build time)
useEffect(() => { useEffect(() => {
interface VersionData { interface VersionData {
version?: string; version?: string;
@@ -24,7 +31,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
.then((res) => res.json() as Promise<VersionData>) .then((res) => res.json() as Promise<VersionData>)
.then((data) => { .then((data) => {
if (data.version) { if (data.version) {
// Format as "version+commit" for full build identification
const fullVersion = data.commit ? `${data.version}+${data.commit}` : data.version; const fullVersion = data.commit ? `${data.version}+${data.commit}` : data.version;
setVersion(fullVersion); setVersion(fullVersion);
} }
@@ -35,20 +41,22 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
}, []); }, []);
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
if (message.trim() && !disabled) { if (message.trim() && !disabled && !isStreaming) {
onSend(message); onSend(message);
setMessage(""); setMessage("");
} }
}, [message, onSend, disabled]); }, [message, onSend, disabled, isStreaming]);
const handleStop = useCallback(() => {
onStopStreaming?.();
}, [onStopStreaming]);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => { (e: KeyboardEvent<HTMLTextAreaElement>) => {
// Enter to send (without Shift)
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
} }
// Ctrl/Cmd + Enter to send (alternative)
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
@@ -61,6 +69,7 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
const maxCharacters = 4000; const maxCharacters = 4000;
const isNearLimit = characterCount > maxCharacters * 0.9; const isNearLimit = characterCount > maxCharacters * 0.9;
const isOverLimit = characterCount > maxCharacters; const isOverLimit = characterCount > maxCharacters;
const isInputDisabled = disabled ?? false;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -69,7 +78,10 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
className="relative rounded-lg border transition-all duration-150" className="relative rounded-lg border transition-all duration-150"
style={{ style={{
backgroundColor: "rgb(var(--surface-0))", backgroundColor: "rgb(var(--surface-0))",
borderColor: disabled ? "rgb(var(--border-default))" : "rgb(var(--border-strong))", borderColor:
isInputDisabled || isStreaming
? "rgb(var(--border-default))"
: "rgb(var(--border-strong))",
}} }}
> >
<textarea <textarea
@@ -79,8 +91,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
setMessage(e.target.value); setMessage(e.target.value);
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Type a message..." placeholder={isStreaming ? "AI is responding..." : "Type a message..."}
disabled={disabled} disabled={isInputDisabled || isStreaming}
rows={1} rows={1}
className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50" className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50"
style={{ style={{
@@ -97,28 +109,47 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
aria-describedby="input-help" aria-describedby="input-help"
/> />
{/* Send Button */} {/* Send / Stop Button */}
<div className="absolute bottom-2 right-2 flex items-center gap-2"> <div className="absolute bottom-2 right-2 flex items-center gap-2">
<button {isStreaming ? (
onClick={handleSubmit} <button
disabled={(disabled ?? !message.trim()) || isOverLimit} onClick={handleStop}
className="btn-primary btn-sm rounded-md" className="btn-sm rounded-md flex items-center gap-1.5"
style={{ style={{
opacity: disabled || !message.trim() || isOverLimit ? 0.5 : 1, backgroundColor: "rgb(var(--semantic-error))",
}} color: "white",
aria-label="Send message" padding: "0.25rem 0.75rem",
> }}
<svg aria-label="Stop generating"
className="h-4 w-4" title="Stop generating"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
> >
<path d="M5 12h14M12 5l7 7-7 7" /> <svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
</svg> <rect x="6" y="6" width="12" height="12" rx="1" />
<span className="hidden sm:inline">Send</span> </svg>
</button> <span className="hidden sm:inline text-sm font-medium">Stop</span>
</button>
) : (
<button
onClick={handleSubmit}
disabled={isInputDisabled || !message.trim() || isOverLimit}
className="btn-primary btn-sm rounded-md"
style={{
opacity: isInputDisabled || !message.trim() || isOverLimit ? 0.5 : 1,
}}
aria-label="Send message"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
<span className="hidden sm:inline">Send</span>
</button>
)}
</div> </div>
</div> </div>
@@ -128,7 +159,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
style={{ color: "rgb(var(--text-muted))" }} style={{ color: "rgb(var(--text-muted))" }}
id="input-help" id="input-help"
> >
{/* Keyboard Shortcuts */}
<div className="hidden items-center gap-4 sm:flex"> <div className="hidden items-center gap-4 sm:flex">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="kbd">Enter</span> <span className="kbd">Enter</span>
@@ -142,10 +172,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
</div> </div>
</div> </div>
{/* Mobile hint */}
<div className="sm:hidden">Tap send or press Enter</div> <div className="sm:hidden">Tap send or press Enter</div>
{/* Character Count */}
<div <div
className="flex items-center gap-2" className="flex items-center gap-2"
style={{ style={{

View File

@@ -1,11 +1,13 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { Message } from "@/hooks/useChat"; import type { Message } from "@/hooks/useChat";
interface MessageListProps { interface MessageListProps {
messages: Message[]; messages: Message[];
isLoading: boolean; isLoading: boolean;
isStreaming?: boolean;
streamingMessageId?: string;
loadingQuip?: string | null; loadingQuip?: string | null;
} }
@@ -14,7 +16,6 @@ interface MessageListProps {
* Extracts <thinking>...</thinking> or <think>...</think> blocks. * Extracts <thinking>...</thinking> or <think>...</think> blocks.
*/ */
function parseThinking(content: string): { thinking: string | null; response: string } { function parseThinking(content: string): { thinking: string | null; response: string } {
// Match <thinking>...</thinking> or <think>...</think> blocks
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi; const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
const matches = content.match(thinkingRegex); const matches = content.match(thinkingRegex);
@@ -22,14 +23,12 @@ function parseThinking(content: string): { thinking: string | null; response: st
return { thinking: null, response: content }; return { thinking: null, response: content };
} }
// Extract thinking content
let thinking = ""; let thinking = "";
for (const match of matches) { for (const match of matches) {
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, ""); const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
thinking += innerContent.trim() + "\n"; thinking += innerContent.trim() + "\n";
} }
// Remove thinking blocks from response
const response = content.replace(thinkingRegex, "").trim(); const response = content.replace(thinkingRegex, "").trim();
const trimmedThinking = thinking.trim(); const trimmedThinking = thinking.trim();
@@ -42,25 +41,47 @@ function parseThinking(content: string): { thinking: string | null; response: st
export function MessageList({ export function MessageList({
messages, messages,
isLoading, isLoading,
isStreaming = false,
streamingMessageId,
loadingQuip, loadingQuip,
}: MessageListProps): React.JSX.Element { }: MessageListProps): React.JSX.Element {
const bottomRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when messages change or streaming tokens arrive
useEffect(() => {
if (isStreaming || isLoading) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages, isStreaming, isLoading]);
return ( return (
<div className="space-y-6" role="log" aria-label="Chat messages"> <div className="space-y-6" role="log" aria-label="Chat messages">
{messages.map((message) => ( {messages.map((message) => (
<MessageBubble key={message.id} message={message} /> <MessageBubble
key={message.id}
message={message}
isStreaming={isStreaming && message.id === streamingMessageId}
/>
))} ))}
{isLoading && <LoadingIndicator {...(loadingQuip != null && { quip: loadingQuip })} />} {isLoading && !isStreaming && (
<LoadingIndicator {...(loadingQuip != null && { quip: loadingQuip })} />
)}
<div ref={bottomRef} />
</div> </div>
); );
} }
function MessageBubble({ message }: { message: Message }): React.JSX.Element { interface MessageBubbleProps {
message: Message;
isStreaming?: boolean;
}
function MessageBubble({ message, isStreaming = false }: MessageBubbleProps): React.JSX.Element {
const isUser = message.role === "user"; const isUser = message.role === "user";
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [thinkingExpanded, setThinkingExpanded] = useState(false); const [thinkingExpanded, setThinkingExpanded] = useState(false);
// Parse thinking from content (or use pre-parsed thinking field)
const { thinking, response } = message.thinking const { thinking, response } = message.thinking
? { thinking: message.thinking, response: message.content } ? { thinking: message.thinking, response: message.content }
: parseThinking(message.content); : parseThinking(message.content);
@@ -73,7 +94,6 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
setCopied(false); setCopied(false);
}, 2000); }, 2000);
} catch (err) { } catch (err) {
// Silently fail - clipboard copy is non-critical
void err; void err;
} }
}, [response]); }, [response]);
@@ -106,8 +126,21 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}> <span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
{isUser ? "You" : "AI Assistant"} {isUser ? "You" : "AI Assistant"}
</span> </span>
{/* Streaming indicator in header */}
{!isUser && isStreaming && (
<span
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{
backgroundColor: "rgb(var(--accent-primary) / 0.15)",
color: "rgb(var(--accent-primary))",
}}
aria-label="Streaming"
>
streaming
</span>
)}
{/* Model indicator for assistant messages */} {/* Model indicator for assistant messages */}
{!isUser && message.model && ( {!isUser && message.model && !isStreaming && (
<span <span
className="px-1.5 py-0.5 rounded text-[10px] font-medium" className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{ style={{
@@ -200,43 +233,54 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
border: isUser ? "none" : "1px solid rgb(var(--border-default))", border: isUser ? "none" : "1px solid rgb(var(--border-default))",
}} }}
> >
<p className="whitespace-pre-wrap text-sm leading-relaxed">{response}</p> <p className="whitespace-pre-wrap text-sm leading-relaxed">
{response}
{/* Copy Button - appears on hover */} {/* Blinking cursor during streaming */}
<button {isStreaming && !isUser && (
onClick={handleCopy} <span
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100" className="streaming-cursor inline-block ml-0.5 align-middle"
style={{ aria-hidden="true"
backgroundColor: "rgb(var(--surface-0))", />
borderColor: "rgb(var(--border-default))",
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
}}
aria-label={copied ? "Copied!" : "Copy message"}
title={copied ? "Copied!" : "Copy to clipboard"}
>
{copied ? (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)} )}
</button> </p>
{/* Copy Button - hidden while streaming */}
{!isStreaming && (
<button
onClick={handleCopy}
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
}}
aria-label={copied ? "Copied!" : "Copy message"}
title={copied ? "Copied!" : "Copy to clipboard"}
>
{copied ? (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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; | {
variant: BadgeVariantType; text: string;
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>
); );

View File

@@ -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;
label: "Active Agents", }
value: "47",
color: "var(--ms-blue-400)", function formatNumber(n: number): string {
trend: { direction: "up", text: "↑ +3 from yesterday" }, return n.toLocaleString();
}, }
{
label: "Tasks Completed", function buildCells(metrics: DashboardMetricsData): MetricCell[] {
value: "1,284", return [
color: "var(--ms-teal-400)", {
trend: { direction: "up", text: "↑ +128 today" }, label: "Active Agents",
}, value: formatNumber(metrics.activeAgents),
{ color: "var(--ms-blue-400)",
label: "Avg Response Time", trend: { direction: "neutral", text: "currently active" },
value: "2.4s", },
color: "var(--ms-purple-400)", {
trend: { direction: "down", text: "↓ -0.3s improved" }, label: "Tasks Completed",
}, value: formatNumber(metrics.tasksCompleted),
{ color: "var(--ms-teal-400)",
label: "Token Usage", trend: { direction: "neutral", text: `of ${formatNumber(metrics.totalTasks)} total` },
value: "3.2M", },
color: "var(--ms-amber-400)", {
trend: { direction: "neutral", text: "78% of budget" }, label: "Total Tasks",
}, value: formatNumber(metrics.totalTasks),
{ color: "var(--ms-purple-400)",
label: "Error Rate", trend: { direction: "neutral", text: "across workspace" },
value: "0.4%", },
color: "var(--ms-red-400)", {
trend: { direction: "down", text: "↓ -0.1% improved" }, label: "In Progress",
}, value: formatNumber(metrics.tasksInProgress),
{ color: "var(--ms-amber-400)",
label: "Active Projects", trend: { direction: "neutral", text: "tasks running" },
value: "8", },
color: "var(--ms-cyan-500)", {
trend: { direction: "neutral", text: "2 deploying" }, label: "Error Rate",
}, value: `${String(metrics.errorRate)}%`,
color: "var(--ms-red-400)",
trend: {
direction: metrics.errorRate > 1 ? "up" : "down",
text: metrics.errorRate > 1 ? "above threshold" : "within threshold",
},
},
{
label: "Active Projects",
value: formatNumber(metrics.activeProjects),
color: "var(--ms-cyan-500)",
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} />;
} }

View File

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

View File

@@ -3,6 +3,22 @@
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";
/* ------------------------------------------------------------------ */
/* Internal display types */
/* ------------------------------------------------------------------ */
type DotVariant = "teal" | "blue" | "amber" | "red" | "muted";
type BadgeVariant =
| "badge-teal"
| "badge-amber"
| "badge-red"
| "badge-blue"
| "badge-muted"
| "badge-purple"
| "badge-pulse";
interface AgentNode { interface AgentNode {
id: string; id: string;
@@ -10,7 +26,8 @@ interface AgentNode {
avatarColor: string; avatarColor: string;
name: string; name: string;
task: string; task: string;
status: "teal" | "blue" | "amber" | "red" | "muted"; status: DotVariant;
statusLabel: string;
} }
interface OrchestratorSession { interface OrchestratorSession {
@@ -18,73 +35,97 @@ interface OrchestratorSession {
orchId: string; orchId: string;
name: string; name: string;
badge: string; badge: string;
badgeVariant: badgeVariant: BadgeVariant;
| "badge-teal"
| "badge-amber"
| "badge-red"
| "badge-blue"
| "badge-muted"
| "badge-purple"
| "badge-pulse";
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>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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> <KnowledgeEditor
<button content={content}
type="button" onChange={onChange}
onClick={() => { placeholder="Write your content here... Supports markdown formatting."
setShowPreview(!showPreview); />
}}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
{showPreview ? "Edit" : "Preview"}
</button>
</div>
{showPreview ? (
<div className="prose prose-sm max-w-none dark:prose-invert p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 min-h-[300px]">
<div className="whitespace-pre-wrap">{content}</div>
</div>
) : (
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => {
onChange(e.target.value);
}}
className="w-full min-h-[300px] p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Write your content here... (Markdown supported)"
/>
<LinkAutocomplete
textareaRef={textareaRef}
onInsert={(newContent) => {
onChange(newContent);
}}
/>
</div>
)}
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Supports Markdown formatting. Type <code className="text-xs">[[</code> to insert links to
other entries.
</p>
</div> </div>
); );
} }

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,89 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { KnowledgeEditor } from "../KnowledgeEditor";
// Mock Tiptap since it requires a full DOM with contenteditable support
vi.mock("@tiptap/react", () => {
const EditorContent = ({ editor }: { editor: unknown }): React.JSX.Element => (
<div data-testid="editor-content" data-editor={editor ? "ready" : "null"} />
);
return {
useEditor: (): null => null,
EditorContent,
};
});
// Mock tiptap-markdown
vi.mock("tiptap-markdown", () => ({
Markdown: {
configure: vi.fn().mockReturnValue({}),
},
}));
// Mock lowlight
vi.mock("lowlight", () => ({
common: {},
createLowlight: vi.fn().mockReturnValue({}),
}));
// Mock extensions
vi.mock("@tiptap/starter-kit", () => ({
default: { configure: vi.fn().mockReturnValue({}) },
}));
vi.mock("@tiptap/extension-link", () => ({
default: { configure: vi.fn().mockReturnValue({}) },
}));
vi.mock("@tiptap/extension-table", () => ({
Table: { configure: vi.fn().mockReturnValue({}) },
}));
vi.mock("@tiptap/extension-table-row", () => ({
TableRow: {},
}));
vi.mock("@tiptap/extension-table-cell", () => ({
TableCell: {},
}));
vi.mock("@tiptap/extension-table-header", () => ({
TableHeader: {},
}));
vi.mock("@tiptap/extension-code-block-lowlight", () => ({
default: { configure: vi.fn().mockReturnValue({}) },
}));
vi.mock("@tiptap/extension-placeholder", () => ({
default: { configure: vi.fn().mockReturnValue({}) },
}));
describe("KnowledgeEditor", (): void => {
const defaultProps = {
content: "",
onChange: vi.fn(),
};
beforeEach((): void => {
vi.clearAllMocks();
});
it("should render loading state when editor is null", (): void => {
render(<KnowledgeEditor {...defaultProps} />);
expect(screen.getByText("Loading editor...")).toBeInTheDocument();
});
it("should render with knowledge-editor class", (): void => {
// When editor is null, the loading fallback renders instead
const { container } = render(<KnowledgeEditor {...defaultProps} />);
expect(container.firstChild).toBeInTheDocument();
});
it("should accept optional placeholder prop", (): void => {
// Smoke test that it doesn't crash with custom placeholder
render(<KnowledgeEditor {...defaultProps} placeholder="Custom placeholder" />);
expect(screen.getByText("Loading editor...")).toBeInTheDocument();
});
it("should accept optional editable prop", (): void => {
// Smoke test that it doesn't crash when read-only
render(<KnowledgeEditor {...defaultProps} editable={false} />);
expect(screen.getByText("Loading editor...")).toBeInTheDocument();
});
});

View File

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

View File

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

View 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,
}}
>
&times;
</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>
</>
);
}

View File

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

View 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}&times;{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,
}}
>
&times;
</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>
</>
);
}

View File

@@ -0,0 +1,63 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { WidgetConfigDialog } from "../WidgetConfigDialog";
describe("WidgetConfigDialog", (): void => {
const defaultProps = {
widgetId: "TasksWidget-abc123",
open: true,
onClose: vi.fn(),
};
beforeEach((): void => {
vi.clearAllMocks();
});
it("should render nothing when closed", (): void => {
const { container } = render(<WidgetConfigDialog {...defaultProps} open={false} />);
expect(container.innerHTML).toBe("");
});
it("should render dialog when open", (): void => {
render(<WidgetConfigDialog {...defaultProps} />);
expect(screen.getByRole("dialog", { name: "Widget Settings" })).toBeInTheDocument();
});
it("should show widget name in header", (): void => {
render(<WidgetConfigDialog {...defaultProps} />);
// TasksWidget is registered with displayName "Tasks"
expect(screen.getByText(/Tasks.*Settings/)).toBeInTheDocument();
});
it("should show placeholder message when no config schema", (): void => {
render(<WidgetConfigDialog {...defaultProps} />);
expect(
screen.getByText("No configuration options available for this widget yet.")
).toBeInTheDocument();
});
it("should call onClose when close button is clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(<WidgetConfigDialog {...defaultProps} />);
await user.click(screen.getByLabelText("Close"));
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
it("should call onClose when footer Close button is clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(<WidgetConfigDialog {...defaultProps} />);
// Footer has a "Close" text button
await user.click(screen.getByText("Close"));
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
it("should handle unknown widget type gracefully", (): void => {
render(<WidgetConfigDialog {...defaultProps} widgetId="UnknownWidget-xyz" />);
// Should show fallback "Widget Settings" when type is not in registry
expect(screen.getByText("Widget Settings")).toBeInTheDocument();
});
});

View File

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

View File

@@ -0,0 +1,101 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { WidgetPicker } from "../WidgetPicker";
import type { WidgetPlacement } from "@mosaic/shared";
describe("WidgetPicker", (): void => {
const defaultProps = {
open: true,
onClose: vi.fn(),
onAddWidget: vi.fn(),
currentLayout: [] as WidgetPlacement[],
};
beforeEach((): void => {
vi.clearAllMocks();
});
it("should render nothing when closed", (): void => {
const { container } = render(<WidgetPicker {...defaultProps} open={false} />);
expect(container.innerHTML).toBe("");
});
it("should render dialog when open", (): void => {
render(<WidgetPicker {...defaultProps} />);
expect(screen.getByRole("dialog", { name: "Add Widget" })).toBeInTheDocument();
});
it("should display Add Widget heading", (): void => {
render(<WidgetPicker {...defaultProps} />);
expect(screen.getByText("Add Widget")).toBeInTheDocument();
});
it("should render search input", (): void => {
render(<WidgetPicker {...defaultProps} />);
expect(screen.getByPlaceholderText("Search widgets...")).toBeInTheDocument();
});
it("should display available widgets", (): void => {
render(<WidgetPicker {...defaultProps} />);
// Widget registry has multiple widgets; at least one Add button should appear
const addButtons = screen.getAllByText("Add");
expect(addButtons.length).toBeGreaterThan(0);
});
it("should filter widgets by search text", async (): Promise<void> => {
const user = userEvent.setup();
render(<WidgetPicker {...defaultProps} />);
const searchInput = screen.getByPlaceholderText("Search widgets...");
// Type a search term that won't match any widget
await user.type(searchInput, "zzz-nonexistent-widget-zzz");
expect(screen.getByText("No widgets found")).toBeInTheDocument();
});
it("should call onAddWidget when Add is clicked", async (): Promise<void> => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<WidgetPicker {...defaultProps} onAddWidget={onAdd} />);
const addButtons = screen.getAllByText("Add");
await user.click(addButtons[0]!);
expect(onAdd).toHaveBeenCalledTimes(1);
const placement = onAdd.mock.calls[0]![0] as WidgetPlacement;
expect(placement).toHaveProperty("i");
expect(placement).toHaveProperty("x");
expect(placement).toHaveProperty("y");
expect(placement).toHaveProperty("w");
expect(placement).toHaveProperty("h");
});
it("should call onClose when close button is clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(<WidgetPicker {...defaultProps} />);
await user.click(screen.getByLabelText("Close"));
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
it("should place new widgets after existing layout items", async (): Promise<void> => {
const user = userEvent.setup();
const onAdd = vi.fn();
const existingLayout: WidgetPlacement[] = [
{ i: "test-1", x: 0, y: 0, w: 6, h: 3 },
{ i: "test-2", x: 0, y: 3, w: 6, h: 2 },
];
render(<WidgetPicker {...defaultProps} onAddWidget={onAdd} currentLayout={existingLayout} />);
const addButtons = screen.getAllByText("Add");
await user.click(addButtons[0]!);
const placement = onAdd.mock.calls[0]![0] as WidgetPlacement;
// Should be placed at y=5 (3+2) to avoid overlap
expect(placement.y).toBe(5);
});
});

View File

@@ -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 },
];

View File

@@ -14,6 +14,7 @@ import type { ChatResponse } from "@/lib/api/chat";
// Mock the API modules - use importOriginal to preserve types/enums // Mock the API modules - use importOriginal to preserve types/enums
vi.mock("@/lib/api/chat", () => ({ vi.mock("@/lib/api/chat", () => ({
sendChatMessage: vi.fn(), sendChatMessage: vi.fn(),
streamChatMessage: vi.fn(),
})); }));
vi.mock("@/lib/api/ideas", async (importOriginal) => { vi.mock("@/lib/api/ideas", async (importOriginal) => {
@@ -30,6 +31,9 @@ vi.mock("@/lib/api/ideas", async (importOriginal) => {
const mockSendChatMessage = chatApi.sendChatMessage as MockedFunction< const mockSendChatMessage = chatApi.sendChatMessage as MockedFunction<
typeof chatApi.sendChatMessage typeof chatApi.sendChatMessage
>; >;
const mockStreamChatMessage = chatApi.streamChatMessage as MockedFunction<
typeof chatApi.streamChatMessage
>;
const mockCreateConversation = ideasApi.createConversation as MockedFunction< const mockCreateConversation = ideasApi.createConversation as MockedFunction<
typeof ideasApi.createConversation typeof ideasApi.createConversation
>; >;
@@ -70,9 +74,62 @@ function createMockIdea(id: string, title: string, content: string): Idea {
} as Idea; } as Idea;
} }
/**
* Configure streamChatMessage to immediately fail,
* triggering the fallback to sendChatMessage.
*/
function makeStreamFail(): void {
mockStreamChatMessage.mockImplementation(
(
_request,
_onChunk,
_onComplete,
onError: (err: Error) => void,
_signal?: AbortSignal
): void => {
// Call synchronously so the Promise rejects immediately
onError(new Error("Streaming not available"));
}
);
}
/**
* Configure streamChatMessage to succeed with given tokens.
* Uses a ref-style object to share cancellation state across the async boundary.
*/
function makeStreamSucceed(tokens: string[]): void {
mockStreamChatMessage.mockImplementation(
(
_request,
onChunk: (chunk: string) => void,
onComplete: () => void,
_onError: (err: Error) => void,
signal?: AbortSignal
): void => {
const state = { cancelled: false };
signal?.addEventListener("abort", () => {
state.cancelled = true;
});
const run = async (): Promise<void> => {
for (const token of tokens) {
if (state.cancelled) return;
await Promise.resolve();
onChunk(token);
}
if (!state.cancelled) {
onComplete();
}
};
void run();
}
);
}
describe("useChat", () => { describe("useChat", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Default: streaming fails so tests exercise the fallback path
makeStreamFail();
}); });
afterEach(() => { afterEach(() => {
@@ -87,13 +144,19 @@ describe("useChat", () => {
expect(result.current.messages[0]?.role).toBe("assistant"); expect(result.current.messages[0]?.role).toBe("assistant");
expect(result.current.messages[0]?.id).toBe("welcome"); expect(result.current.messages[0]?.id).toBe("welcome");
expect(result.current.isLoading).toBe(false); expect(result.current.isLoading).toBe(false);
expect(result.current.isStreaming).toBe(false);
expect(result.current.error).toBeNull(); expect(result.current.error).toBeNull();
expect(result.current.conversationId).toBeNull(); expect(result.current.conversationId).toBeNull();
}); });
it("should expose abortStream function", () => {
const { result } = renderHook(() => useChat());
expect(typeof result.current.abortStream).toBe("function");
});
}); });
describe("sendMessage", () => { describe("sendMessage (fallback path when streaming fails)", () => {
it("should add user message and assistant response", async () => { it("should add user message and assistant response via fallback", async () => {
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Hello there!")); mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Hello there!"));
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", "")); mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
@@ -119,47 +182,13 @@ describe("useChat", () => {
}); });
expect(mockSendChatMessage).not.toHaveBeenCalled(); expect(mockSendChatMessage).not.toHaveBeenCalled();
expect(mockStreamChatMessage).not.toHaveBeenCalled();
expect(result.current.messages).toHaveLength(1); // only welcome expect(result.current.messages).toHaveLength(1); // only welcome
}); });
it("should not send while loading", async () => {
let resolveFirst: ((value: ChatResponse) => void) | undefined;
const firstPromise = new Promise<ChatResponse>((resolve) => {
resolveFirst = resolve;
});
mockSendChatMessage.mockReturnValueOnce(firstPromise);
const { result } = renderHook(() => useChat());
// Start first message
act(() => {
void result.current.sendMessage("First");
});
expect(result.current.isLoading).toBe(true);
// Try to send second while loading
await act(async () => {
await result.current.sendMessage("Second");
});
// Should only have one call
expect(mockSendChatMessage).toHaveBeenCalledTimes(1);
// Cleanup - resolve the pending promise
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
await act(async () => {
if (resolveFirst) {
resolveFirst(createMockChatResponse("Response"));
}
// Allow promise to settle
await Promise.resolve();
});
});
it("should handle API errors gracefully", async () => { it("should handle API errors gracefully", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockSendChatMessage.mockRejectedValueOnce(new Error("API Error")); mockSendChatMessage.mockRejectedValueOnce(new Error("API Error"));
const onError = vi.fn(); const onError = vi.fn();
@@ -171,46 +200,178 @@ describe("useChat", () => {
expect(result.current.error).toBe("Unable to send message. Please try again."); expect(result.current.error).toBe("Unable to send message. Please try again.");
expect(onError).toHaveBeenCalledWith(expect.any(Error)); expect(onError).toHaveBeenCalledWith(expect.any(Error));
// Should have welcome + user + error message
expect(result.current.messages).toHaveLength(3); expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again."); expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
}); });
}); });
describe("streaming path", () => {
it("should stream tokens into assistant message", async () => {
const tokens = ["Hello", " world", "!"];
makeStreamSucceed(tokens);
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
const { result } = renderHook(() => useChat());
await act(async () => {
await result.current.sendMessage("Hi");
});
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[2]?.role).toBe("assistant");
expect(result.current.messages[2]?.content).toBe("Hello world!");
});
it("should set isStreaming true during streaming then false when done", async () => {
let capturedOnChunk: ((chunk: string) => void) | undefined;
let capturedOnComplete: (() => void) | undefined;
mockStreamChatMessage.mockImplementation(
(
_request,
onChunk: (chunk: string) => void,
onComplete: () => void,
_onError: (err: Error) => void
): void => {
capturedOnChunk = onChunk;
capturedOnComplete = onComplete;
}
);
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
const { result } = renderHook(() => useChat());
let sendDone = false;
act(() => {
void result.current.sendMessage("Hello").then(() => {
sendDone = true;
});
});
// Send first token (triggers streaming state)
await act(async () => {
capturedOnChunk?.("Hello");
await Promise.resolve();
});
expect(result.current.isStreaming).toBe(true);
// Complete the stream
await act(async () => {
capturedOnComplete?.();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.isStreaming).toBe(false);
expect(sendDone).toBe(true);
});
it("should keep partial content on abort", async () => {
let capturedOnChunk: ((chunk: string) => void) | undefined;
mockStreamChatMessage.mockImplementation(
(
_request,
onChunk: (chunk: string) => void,
_onComplete: () => void,
_onError: (err: Error) => void,
signal?: AbortSignal
): void => {
capturedOnChunk = onChunk;
if (signal) {
signal.addEventListener("abort", () => {
// Stream aborted
});
}
}
);
const { result } = renderHook(() => useChat());
act(() => {
void result.current.sendMessage("Hello");
});
await act(async () => {
capturedOnChunk?.("Partial");
capturedOnChunk?.(" content");
await Promise.resolve();
});
await act(async () => {
result.current.abortStream();
await Promise.resolve();
});
expect(result.current.isStreaming).toBe(false);
const assistantMsg = result.current.messages.find(
(m) => m.role === "assistant" && m.id !== "welcome"
);
expect(assistantMsg?.content).toBe("Partial content");
});
it("should not send while streaming", async () => {
let capturedOnChunk: ((chunk: string) => void) | undefined;
mockStreamChatMessage.mockImplementation(
(
_request,
onChunk: (chunk: string) => void,
_onComplete: () => void,
_onError: (err: Error) => void
): void => {
capturedOnChunk = onChunk;
}
);
const { result } = renderHook(() => useChat());
act(() => {
void result.current.sendMessage("First");
});
await act(async () => {
capturedOnChunk?.("token");
await Promise.resolve();
});
expect(result.current.isStreaming).toBe(true);
await act(async () => {
await result.current.sendMessage("Second");
});
// Only one stream call
expect(mockStreamChatMessage).toHaveBeenCalledTimes(1);
});
});
describe("rapid sends - stale closure prevention", () => { describe("rapid sends - stale closure prevention", () => {
it("should not lose messages on rapid sequential sends", async () => { it("should not lose messages on rapid sequential sends", async () => {
// This test verifies that functional state updates prevent message loss // Use streaming success path for deterministic behavior
// when multiple messages are sent in quick succession makeStreamSucceed(["Response 1"]);
let callCount = 0;
mockSendChatMessage.mockImplementation(async (): Promise<ChatResponse> => {
callCount++;
// Small delay to simulate network
await Promise.resolve();
return createMockChatResponse(`Response ${String(callCount)}`);
});
mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", "")); mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", ""));
const { result } = renderHook(() => useChat()); const { result } = renderHook(() => useChat());
// Send first message
await act(async () => { await act(async () => {
await result.current.sendMessage("Message 1"); await result.current.sendMessage("Message 1");
}); });
// Verify first message cycle complete
expect(result.current.messages).toHaveLength(3); // welcome + user1 + assistant1 expect(result.current.messages).toHaveLength(3); // welcome + user1 + assistant1
// Send second message makeStreamSucceed(["Response 2"]);
await act(async () => { await act(async () => {
await result.current.sendMessage("Message 2"); await result.current.sendMessage("Message 2");
}); });
// Verify all messages are present (no data loss)
expect(result.current.messages).toHaveLength(5); // welcome + user1 + assistant1 + user2 + assistant2 expect(result.current.messages).toHaveLength(5); // welcome + user1 + assistant1 + user2 + assistant2
// Verify message order and content
const userMessages = result.current.messages.filter((m) => m.role === "user"); const userMessages = result.current.messages.filter((m) => m.role === "user");
expect(userMessages).toHaveLength(2); expect(userMessages).toHaveLength(2);
expect(userMessages[0]?.content).toBe("Message 1"); expect(userMessages[0]?.content).toBe("Message 1");
@@ -218,69 +379,56 @@ describe("useChat", () => {
}); });
it("should use functional updates for all message state changes", async () => { it("should use functional updates for all message state changes", async () => {
// This test verifies that the implementation uses functional updates
// by checking that messages accumulate correctly
mockSendChatMessage.mockResolvedValue(createMockChatResponse("Response"));
mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", "")); mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", ""));
const { result } = renderHook(() => useChat()); const { result } = renderHook(() => useChat());
// Track message count after each operation
const messageCounts: number[] = []; const messageCounts: number[] = [];
makeStreamSucceed(["R1"]);
await act(async () => { await act(async () => {
await result.current.sendMessage("Test 1"); await result.current.sendMessage("Test 1");
}); });
messageCounts.push(result.current.messages.length); messageCounts.push(result.current.messages.length);
makeStreamSucceed(["R2"]);
await act(async () => { await act(async () => {
await result.current.sendMessage("Test 2"); await result.current.sendMessage("Test 2");
}); });
messageCounts.push(result.current.messages.length); messageCounts.push(result.current.messages.length);
makeStreamSucceed(["R3"]);
await act(async () => { await act(async () => {
await result.current.sendMessage("Test 3"); await result.current.sendMessage("Test 3");
}); });
messageCounts.push(result.current.messages.length); messageCounts.push(result.current.messages.length);
// Should accumulate: 3, 5, 7 (welcome + pairs of user/assistant)
expect(messageCounts).toEqual([3, 5, 7]); expect(messageCounts).toEqual([3, 5, 7]);
// Verify final state has all messages
expect(result.current.messages).toHaveLength(7); expect(result.current.messages).toHaveLength(7);
const userMessages = result.current.messages.filter((m) => m.role === "user"); const userMessages = result.current.messages.filter((m) => m.role === "user");
expect(userMessages).toHaveLength(3); expect(userMessages).toHaveLength(3);
}); });
it("should maintain correct message order with ref-based state tracking", async () => { it("should maintain correct message order with ref-based state tracking", async () => {
// This test verifies that messagesRef is properly synchronized
const responses = ["First response", "Second response", "Third response"];
let responseIndex = 0;
mockSendChatMessage.mockImplementation((): Promise<ChatResponse> => {
const response = responses[responseIndex++];
return Promise.resolve(createMockChatResponse(response ?? ""));
});
mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", "")); mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", ""));
const { result } = renderHook(() => useChat()); const { result } = renderHook(() => useChat());
makeStreamSucceed(["First response"]);
await act(async () => { await act(async () => {
await result.current.sendMessage("Query 1"); await result.current.sendMessage("Query 1");
}); });
makeStreamSucceed(["Second response"]);
await act(async () => { await act(async () => {
await result.current.sendMessage("Query 2"); await result.current.sendMessage("Query 2");
}); });
makeStreamSucceed(["Third response"]);
await act(async () => { await act(async () => {
await result.current.sendMessage("Query 3"); await result.current.sendMessage("Query 3");
}); });
// Verify messages are in correct order
const messages = result.current.messages; const messages = result.current.messages;
expect(messages[0]?.id).toBe("welcome"); expect(messages[0]?.id).toBe("welcome");
expect(messages[1]?.content).toBe("Query 1"); expect(messages[1]?.content).toBe("Query 1");
@@ -337,14 +485,12 @@ describe("useChat", () => {
await result.current.loadConversation("conv-bad"); await result.current.loadConversation("conv-bad");
}); });
// Should fall back to welcome message
expect(result.current.messages).toHaveLength(1); expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]?.id).toBe("welcome"); expect(result.current.messages[0]?.id).toBe("welcome");
}); });
it("should fall back to welcome message when stored data has wrong shape", async () => { it("should fall back to welcome message when stored data has wrong shape", async () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(console, "warn").mockImplementation(() => undefined);
// Valid JSON but wrong shape (object instead of array, missing required fields)
mockGetIdea.mockResolvedValueOnce( mockGetIdea.mockResolvedValueOnce(
createMockIdea("conv-bad", "Wrong Shape", JSON.stringify({ not: "an array" })) createMockIdea("conv-bad", "Wrong Shape", JSON.stringify({ not: "an array" }))
); );
@@ -408,7 +554,6 @@ describe("useChat", () => {
const { result } = renderHook(() => useChat()); const { result } = renderHook(() => useChat());
// Should resolve without throwing - errors are handled internally
await act(async () => { await act(async () => {
await expect(result.current.loadConversation("conv-err")).resolves.toBeUndefined(); await expect(result.current.loadConversation("conv-err")).resolves.toBeUndefined();
}); });
@@ -419,19 +564,17 @@ describe("useChat", () => {
describe("startNewConversation", () => { describe("startNewConversation", () => {
it("should reset to initial state", async () => { it("should reset to initial state", async () => {
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response")); makeStreamSucceed(["Response"]);
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", "")); mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
const { result } = renderHook(() => useChat()); const { result } = renderHook(() => useChat());
// Send a message to have some state
await act(async () => { await act(async () => {
await result.current.sendMessage("Hello"); await result.current.sendMessage("Hello");
}); });
expect(result.current.messages.length).toBeGreaterThan(1); expect(result.current.messages.length).toBeGreaterThan(1);
// Start new conversation
act(() => { act(() => {
result.current.startNewConversation(); result.current.startNewConversation();
}); });
@@ -446,6 +589,7 @@ describe("useChat", () => {
describe("clearError", () => { describe("clearError", () => {
it("should clear error state", async () => { it("should clear error state", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockSendChatMessage.mockRejectedValueOnce(new Error("Test error")); mockSendChatMessage.mockRejectedValueOnce(new Error("Test error"));
const { result } = renderHook(() => useChat()); const { result } = renderHook(() => useChat());
@@ -467,6 +611,7 @@ describe("useChat", () => {
describe("error context logging", () => { describe("error context logging", () => {
it("should log comprehensive error context when sendMessage fails", async () => { it("should log comprehensive error context when sendMessage fails", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockSendChatMessage.mockRejectedValueOnce(new Error("LLM timeout")); mockSendChatMessage.mockRejectedValueOnce(new Error("LLM timeout"));
const { result } = renderHook(() => useChat({ model: "llama3.2" })); const { result } = renderHook(() => useChat({ model: "llama3.2" }));
@@ -489,6 +634,7 @@ describe("useChat", () => {
it("should truncate long message previews to 50 characters", async () => { it("should truncate long message previews to 50 characters", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockSendChatMessage.mockRejectedValueOnce(new Error("Failed")); mockSendChatMessage.mockRejectedValueOnce(new Error("Failed"));
const longMessage = "A".repeat(100); const longMessage = "A".repeat(100);
@@ -509,9 +655,10 @@ describe("useChat", () => {
it("should include message count in error context", async () => { it("should include message count in error context", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
// First successful message // First successful message via streaming
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK")); makeStreamSucceed(["OK"]);
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", "")); mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
const { result } = renderHook(() => useChat()); const { result } = renderHook(() => useChat());
@@ -520,14 +667,14 @@ describe("useChat", () => {
await result.current.sendMessage("First"); await result.current.sendMessage("First");
}); });
// Second message fails // Second message: streaming fails, fallback fails
makeStreamFail();
mockSendChatMessage.mockRejectedValueOnce(new Error("Fail")); mockSendChatMessage.mockRejectedValueOnce(new Error("Fail"));
await act(async () => { await act(async () => {
await result.current.sendMessage("Second"); await result.current.sendMessage("Second");
}); });
// messageCount should reflect messages including the new user message
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(
"Failed to send chat message", "Failed to send chat message",
expect.objectContaining({ expect.objectContaining({
@@ -540,6 +687,7 @@ describe("useChat", () => {
describe("LLM vs persistence error separation", () => { describe("LLM vs persistence error separation", () => {
it("should show LLM error and add error message to chat when API fails", async () => { it("should show LLM error and add error message to chat when API fails", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available")); mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available"));
const { result } = renderHook(() => useChat()); const { result } = renderHook(() => useChat());
@@ -549,13 +697,29 @@ describe("useChat", () => {
}); });
expect(result.current.error).toBe("Unable to send message. Please try again."); expect(result.current.error).toBe("Unable to send message. Please try again.");
// Should have welcome + user + error message
expect(result.current.messages).toHaveLength(3); expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again."); expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
}); });
it("should keep assistant message visible when save fails", async () => { it("should keep assistant message visible when save fails (streaming path)", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined);
makeStreamSucceed(["Great answer!"]);
mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost"));
const { result } = renderHook(() => useChat());
await act(async () => {
await result.current.sendMessage("Hello");
});
expect(result.current.messages).toHaveLength(3); // welcome + user + assistant
expect(result.current.messages[2]?.content).toBe("Great answer!");
expect(result.current.error).toContain("Message sent but failed to save");
});
it("should keep assistant message visible when save fails (fallback path)", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Great answer!")); mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Great answer!"));
mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost")); mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost"));
@@ -565,16 +729,14 @@ describe("useChat", () => {
await result.current.sendMessage("Hello"); await result.current.sendMessage("Hello");
}); });
// Assistant message should still be visible expect(result.current.messages).toHaveLength(3);
expect(result.current.messages).toHaveLength(3); // welcome + user + assistant
expect(result.current.messages[2]?.content).toBe("Great answer!"); expect(result.current.messages[2]?.content).toBe("Great answer!");
// Error should indicate persistence failure
expect(result.current.error).toContain("Message sent but failed to save"); expect(result.current.error).toContain("Message sent but failed to save");
}); });
it("should log with PERSISTENCE_ERROR type when save fails", async () => { it("should log with PERSISTENCE_ERROR type when save fails", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response")); mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
mockCreateConversation.mockRejectedValueOnce(new Error("DB error")); mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
@@ -591,7 +753,6 @@ describe("useChat", () => {
}) })
); );
// Should NOT have logged as LLM_ERROR
const llmErrorCalls = consoleSpy.mock.calls.filter((call) => { const llmErrorCalls = consoleSpy.mock.calls.filter((call) => {
const ctx: unknown = call[1]; const ctx: unknown = call[1];
return ( return (
@@ -606,8 +767,9 @@ describe("useChat", () => {
it("should use different user-facing messages for LLM vs save errors", async () => { it("should use different user-facing messages for LLM vs save errors", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
// Test LLM error message // LLM error path (streaming fails + fallback fails)
mockSendChatMessage.mockRejectedValueOnce(new Error("Timeout")); mockSendChatMessage.mockRejectedValueOnce(new Error("Timeout"));
const { result: result1 } = renderHook(() => useChat()); const { result: result1 } = renderHook(() => useChat());
@@ -617,8 +779,8 @@ describe("useChat", () => {
const llmError = result1.current.error; const llmError = result1.current.error;
// Test save error message // Save error path (streaming succeeds, save fails)
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK")); makeStreamSucceed(["OK"]);
mockCreateConversation.mockRejectedValueOnce(new Error("DB down")); mockCreateConversation.mockRejectedValueOnce(new Error("DB down"));
const { result: result2 } = renderHook(() => useChat()); const { result: result2 } = renderHook(() => useChat());
@@ -628,7 +790,6 @@ describe("useChat", () => {
const saveError = result2.current.error; const saveError = result2.current.error;
// They should be different
expect(llmError).toBe("Unable to send message. Please try again."); expect(llmError).toBe("Unable to send message. Please try again.");
expect(saveError).toContain("Message sent but failed to save"); expect(saveError).toContain("Message sent but failed to save");
expect(llmError).not.toEqual(saveError); expect(llmError).not.toEqual(saveError);
@@ -636,6 +797,7 @@ describe("useChat", () => {
it("should handle non-Error throws from LLM API", async () => { it("should handle non-Error throws from LLM API", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockSendChatMessage.mockRejectedValueOnce("string error"); mockSendChatMessage.mockRejectedValueOnce("string error");
const onError = vi.fn(); const onError = vi.fn();
@@ -652,7 +814,8 @@ describe("useChat", () => {
it("should handle non-Error throws from persistence layer", async () => { it("should handle non-Error throws from persistence layer", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined);
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK")); vi.spyOn(console, "warn").mockImplementation(() => undefined);
makeStreamSucceed(["OK"]);
mockCreateConversation.mockRejectedValueOnce("DB string error"); mockCreateConversation.mockRejectedValueOnce("DB string error");
const onError = vi.fn(); const onError = vi.fn();
@@ -662,7 +825,6 @@ describe("useChat", () => {
await result.current.sendMessage("Hello"); await result.current.sendMessage("Hello");
}); });
// Assistant message should still be visible
expect(result.current.messages[2]?.content).toBe("OK"); expect(result.current.messages[2]?.content).toBe("OK");
expect(result.current.error).toBe("Message sent but failed to save. Please try again."); expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
expect(onError).toHaveBeenCalledWith(expect.any(Error)); expect(onError).toHaveBeenCalledWith(expect.any(Error));
@@ -670,8 +832,9 @@ describe("useChat", () => {
it("should handle updateConversation failure for existing conversations", async () => { it("should handle updateConversation failure for existing conversations", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
// First message succeeds and creates conversation // First message via fallback
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("First response")); mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("First response"));
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", "")); mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
@@ -683,7 +846,8 @@ describe("useChat", () => {
expect(result.current.conversationId).toBe("conv-1"); expect(result.current.conversationId).toBe("conv-1");
// Second message succeeds but updateConversation fails // Second message via fallback, updateConversation fails
makeStreamFail();
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Second response")); mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Second response"));
mockUpdateConversation.mockRejectedValueOnce(new Error("Connection reset")); mockUpdateConversation.mockRejectedValueOnce(new Error("Connection reset"));
@@ -691,8 +855,10 @@ describe("useChat", () => {
await result.current.sendMessage("Second"); await result.current.sendMessage("Second");
}); });
// Assistant message should still be visible const assistantMessages = result.current.messages.filter(
expect(result.current.messages[4]?.content).toBe("Second response"); (m) => m.role === "assistant" && m.id !== "welcome"
);
expect(assistantMessages[assistantMessages.length - 1]?.content).toBe("Second response");
expect(result.current.error).toBe("Message sent but failed to save. Please try again."); expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
}); });
}); });

View File

@@ -4,7 +4,11 @@
*/ */
import { useState, useCallback, useRef } from "react"; import { useState, useCallback, useRef } from "react";
import { sendChatMessage, type ChatMessage as ApiChatMessage } from "@/lib/api/chat"; import {
sendChatMessage,
streamChatMessage,
type ChatMessage as ApiChatMessage,
} from "@/lib/api/chat";
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas"; import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json"; import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
@@ -33,10 +37,12 @@ export interface UseChatOptions {
export interface UseChatReturn { export interface UseChatReturn {
messages: Message[]; messages: Message[];
isLoading: boolean; isLoading: boolean;
isStreaming: boolean;
error: string | null; error: string | null;
conversationId: string | null; conversationId: string | null;
conversationTitle: string | null; conversationTitle: string | null;
sendMessage: (content: string) => Promise<void>; sendMessage: (content: string) => Promise<void>;
abortStream: () => void;
loadConversation: (ideaId: string) => Promise<void>; loadConversation: (ideaId: string) => Promise<void>;
startNewConversation: (projectId?: string | null) => void; startNewConversation: (projectId?: string | null) => void;
setMessages: React.Dispatch<React.SetStateAction<Message[]>>; setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
@@ -66,6 +72,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
const [messages, setMessages] = useState<Message[]>([WELCOME_MESSAGE]); const [messages, setMessages] = useState<Message[]>([WELCOME_MESSAGE]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [conversationId, setConversationId] = useState<string | null>(null); const [conversationId, setConversationId] = useState<string | null>(null);
const [conversationTitle, setConversationTitle] = useState<string | null>(null); const [conversationTitle, setConversationTitle] = useState<string | null>(null);
@@ -78,6 +85,16 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
const messagesRef = useRef<Message[]>(messages); const messagesRef = useRef<Message[]>(messages);
messagesRef.current = messages; messagesRef.current = messages;
// AbortController ref for the active stream
const abortControllerRef = useRef<AbortController | null>(null);
// Track conversation state in refs to avoid stale closures in streaming callbacks
const conversationIdRef = useRef<string | null>(conversationId);
conversationIdRef.current = conversationId;
const conversationTitleRef = useRef<string | null>(conversationTitle);
conversationTitleRef.current = conversationTitle;
/** /**
* Convert our Message format to API ChatMessage format * Convert our Message format to API ChatMessage format
*/ */
@@ -119,44 +136,57 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
}, []); }, []);
/** /**
* Save conversation to backend * Save conversation to backend.
* Uses refs for conversation state to avoid stale closures in streaming callbacks.
*/ */
const saveConversation = useCallback( const saveConversation = useCallback(
async (msgs: Message[], title: string): Promise<string> => { async (msgs: Message[], title: string): Promise<string> => {
const content = serializeMessages(msgs); const content = serializeMessages(msgs);
const currentConvId = conversationIdRef.current;
if (conversationId) { if (currentConvId) {
// Update existing conversation await updateConversation(currentConvId, content, title);
await updateConversation(conversationId, content, title); return currentConvId;
return conversationId;
} else { } else {
// Create new conversation
const idea = await createConversation(title, content, projectIdRef.current ?? undefined); const idea = await createConversation(title, content, projectIdRef.current ?? undefined);
setConversationId(idea.id); setConversationId(idea.id);
setConversationTitle(title); setConversationTitle(title);
conversationIdRef.current = idea.id;
conversationTitleRef.current = title;
return idea.id; return idea.id;
} }
}, },
[conversationId, serializeMessages] [serializeMessages]
); );
/** /**
* Send a message to the LLM and save the conversation * Abort an active stream
*/
const abortStream = useCallback((): void => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsStreaming(false);
setIsLoading(false);
}, []);
/**
* Send a message to the LLM using streaming, with fallback to non-streaming
*/ */
const sendMessage = useCallback( const sendMessage = useCallback(
async (content: string): Promise<void> => { async (content: string): Promise<void> => {
if (!content.trim() || isLoading) { if (!content.trim() || isLoading || isStreaming) {
return; return;
} }
const userMessage: Message = { const userMessage: Message = {
id: `user-${Date.now().toString()}`, id: `user-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`,
role: "user", role: "user",
content: content.trim(), content: content.trim(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
// Add user message immediately using functional update
setMessages((prev) => { setMessages((prev) => {
const updated = [...prev, userMessage]; const updated = [...prev, userMessage];
messagesRef.current = updated; messagesRef.current = updated;
@@ -165,95 +195,186 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const assistantMessageId = `assistant-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`;
const placeholderMessage: Message = {
id: assistantMessageId,
role: "assistant",
content: "",
createdAt: new Date().toISOString(),
model,
};
const currentMessages = messagesRef.current;
const apiMessages = convertToApiMessages(currentMessages);
const request = {
model,
messages: apiMessages,
...(temperature !== undefined && { temperature }),
...(maxTokens !== undefined && { maxTokens }),
...(systemPrompt !== undefined && { systemPrompt }),
};
const controller = new AbortController();
abortControllerRef.current = controller;
let streamingSucceeded = false;
try { try {
// Prepare API request - use ref to get current messages (prevents stale closure) await new Promise<void>((resolve, reject) => {
const currentMessages = messagesRef.current; let hasReceivedData = false;
const apiMessages = convertToApiMessages(currentMessages);
const request = { streamChatMessage(
model, request,
messages: apiMessages, (chunk: string) => {
...(temperature !== undefined && { temperature }), if (!hasReceivedData) {
...(maxTokens !== undefined && { maxTokens }), hasReceivedData = true;
...(systemPrompt !== undefined && { systemPrompt }), setIsLoading(false);
}; setIsStreaming(true);
setMessages((prev) => {
const updated = [...prev, { ...placeholderMessage }];
messagesRef.current = updated;
return updated;
});
}
// Call LLM API setMessages((prev) => {
const response = await sendChatMessage(request); const updated = prev.map((msg) =>
msg.id === assistantMessageId ? { ...msg, content: msg.content + chunk } : msg
// Create assistant message );
const assistantMessage: Message = { messagesRef.current = updated;
id: `assistant-${Date.now().toString()}`, return updated;
role: "assistant", });
content: response.message.content, },
createdAt: new Date().toISOString(), () => {
model: response.model, streamingSucceeded = true;
promptTokens: response.promptEvalCount ?? 0, setIsStreaming(false);
completionTokens: response.evalCount ?? 0, abortControllerRef.current = null;
totalTokens: (response.promptEvalCount ?? 0) + (response.evalCount ?? 0), resolve();
}; },
(err: Error) => {
// Add assistant message using functional update reject(err);
let finalMessages: Message[] = []; },
setMessages((prev) => { controller.signal
finalMessages = [...prev, assistantMessage]; );
messagesRef.current = finalMessages;
return finalMessages;
}); });
} catch (err: unknown) {
if (controller.signal.aborted) {
setIsStreaming(false);
setIsLoading(false);
abortControllerRef.current = null;
// Generate title from first user message if this is a new conversation // Remove placeholder if no content was received
const isFirstMessage = setMessages((prev) => {
!conversationId && finalMessages.filter((m) => m.role === "user").length === 1; const assistantMsg = prev.find((m) => m.id === assistantMessageId);
const title = isFirstMessage if (assistantMsg?.content === "") {
? generateTitle(content) const updated = prev.filter((m) => m.id !== assistantMessageId);
: (conversationTitle ?? "Chat Conversation"); messagesRef.current = updated;
return updated;
// Save conversation (separate error handling from LLM errors) }
try { messagesRef.current = prev;
await saveConversation(finalMessages, title); return prev;
} catch (saveErr) {
const saveErrorMsg =
saveErr instanceof Error ? saveErr.message : "Unknown persistence error";
setError("Message sent but failed to save. Please try again.");
onError?.(saveErr instanceof Error ? saveErr : new Error(saveErrorMsg));
console.error("Failed to save conversation", {
error: saveErr,
errorType: "PERSISTENCE_ERROR",
conversationId,
detail: saveErrorMsg,
}); });
return;
} }
} catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to send message"; // Streaming failed — fall back to non-streaming
setError("Unable to send message. Please try again."); console.warn("Streaming failed, falling back to non-streaming", {
onError?.(err instanceof Error ? err : new Error(errorMsg)); error: err instanceof Error ? err : new Error(String(err)),
console.error("Failed to send chat message", {
error: err,
errorType: "LLM_ERROR",
conversationId,
messageLength: content.length,
messagePreview: content.substring(0, 50),
model,
messageCount: messagesRef.current.length,
timestamp: new Date().toISOString(),
}); });
// Add error message to chat setMessages((prev) => {
const errorMessage: Message = { const withoutPlaceholder = prev.filter((m) => m.id !== assistantMessageId);
id: `error-${String(Date.now())}`, messagesRef.current = withoutPlaceholder;
role: "assistant", return withoutPlaceholder;
content: "Something went wrong. Please try again.", });
createdAt: new Date().toISOString(), setIsStreaming(false);
};
setMessages((prev) => [...prev, errorMessage]); try {
} finally { const response = await sendChatMessage(request);
setIsLoading(false);
const assistantMessage: Message = {
id: `assistant-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`,
role: "assistant",
content: response.message.content,
createdAt: new Date().toISOString(),
model: response.model,
promptTokens: response.promptEvalCount ?? 0,
completionTokens: response.evalCount ?? 0,
totalTokens: (response.promptEvalCount ?? 0) + (response.evalCount ?? 0),
};
setMessages((prev) => {
const updated = [...prev, assistantMessage];
messagesRef.current = updated;
return updated;
});
streamingSucceeded = true;
} catch (fallbackErr: unknown) {
const errorMsg =
fallbackErr instanceof Error ? fallbackErr.message : "Failed to send message";
setError("Unable to send message. Please try again.");
onError?.(fallbackErr instanceof Error ? fallbackErr : new Error(errorMsg));
console.error("Failed to send chat message", {
error: fallbackErr,
errorType: "LLM_ERROR",
conversationId: conversationIdRef.current,
messageLength: content.length,
messagePreview: content.substring(0, 50),
model,
messageCount: messagesRef.current.length,
timestamp: new Date().toISOString(),
});
const errorMessage: Message = {
id: `error-${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`,
role: "assistant",
content: "Something went wrong. Please try again.",
createdAt: new Date().toISOString(),
};
setMessages((prev) => {
const updated = [...prev, errorMessage];
messagesRef.current = updated;
return updated;
});
setIsLoading(false);
return;
}
}
setIsLoading(false);
if (!streamingSucceeded) {
return;
}
const finalMessages = messagesRef.current;
const isFirstMessage =
!conversationIdRef.current && finalMessages.filter((m) => m.role === "user").length === 1;
const title = isFirstMessage
? generateTitle(content)
: (conversationTitleRef.current ?? "Chat Conversation");
try {
await saveConversation(finalMessages, title);
} catch (saveErr) {
const saveErrorMsg =
saveErr instanceof Error ? saveErr.message : "Unknown persistence error";
setError("Message sent but failed to save. Please try again.");
onError?.(saveErr instanceof Error ? saveErr : new Error(saveErrorMsg));
console.error("Failed to save conversation", {
error: saveErr,
errorType: "PERSISTENCE_ERROR",
conversationId: conversationIdRef.current,
detail: saveErrorMsg,
});
} }
}, },
[ [
isLoading, isLoading,
conversationId, isStreaming,
conversationTitle,
model, model,
temperature, temperature,
maxTokens, maxTokens,
@@ -280,6 +401,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
setMessages(msgs); setMessages(msgs);
setConversationId(idea.id); setConversationId(idea.id);
setConversationTitle(idea.title ?? null); setConversationTitle(idea.title ?? null);
conversationIdRef.current = idea.id;
conversationTitleRef.current = idea.title ?? null;
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to load conversation"; const errorMsg = err instanceof Error ? err.message : "Failed to load conversation";
setError("Unable to load conversation. Please try again."); setError("Unable to load conversation. Please try again.");
@@ -305,6 +428,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
setConversationId(null); setConversationId(null);
setConversationTitle(null); setConversationTitle(null);
setError(null); setError(null);
conversationIdRef.current = null;
conversationTitleRef.current = null;
projectIdRef.current = newProjectId ?? null; projectIdRef.current = newProjectId ?? null;
}, []); }, []);
@@ -318,10 +443,12 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
return { return {
messages, messages,
isLoading, isLoading,
isStreaming,
error, error,
conversationId, conversationId,
conversationTitle, conversationTitle,
sendMessage, sendMessage,
abortStream,
loadConversation, loadConversation,
startNewConversation, startNewConversation,
setMessages, setMessages,

View File

@@ -3,7 +3,8 @@
* Handles LLM chat interactions via /api/llm/chat * Handles LLM chat interactions via /api/llm/chat
*/ */
import { apiPost } from "./client"; import { apiPost, fetchCsrfToken, getCsrfToken } from "./client";
import { API_BASE_URL } from "../config";
export interface ChatMessage { export interface ChatMessage {
role: "system" | "user" | "assistant"; role: "system" | "user" | "assistant";
@@ -31,6 +32,19 @@ export interface ChatResponse {
evalCount?: number; evalCount?: number;
} }
/**
* Parsed SSE data chunk from the LLM stream
*/
interface SseChunk {
error?: string;
message?: {
role: string;
content: string;
};
model?: string;
done?: boolean;
}
/** /**
* Send a chat message to the LLM * Send a chat message to the LLM
*/ */
@@ -39,19 +53,122 @@ export async function sendChatMessage(request: ChatRequest): Promise<ChatRespons
} }
/** /**
* Stream a chat message from the LLM (not implemented yet) * Get or refresh the CSRF token for streaming requests.
* TODO: Implement streaming support */
async function ensureCsrfTokenForStream(): Promise<string> {
const existing = getCsrfToken();
if (existing) {
return existing;
}
return fetchCsrfToken();
}
/**
* Stream a chat message from the LLM using SSE over fetch.
*
* The backend accepts stream: true in the request body and responds with
* Server-Sent Events:
* data: {"message":{"content":"token"},...}\n\n for each token
* data: [DONE]\n\n when the stream is complete
* data: {"error":"message"}\n\n on error
*
* @param request - Chat request (stream field will be forced to true)
* @param onChunk - Called with each token string as it arrives
* @param onComplete - Called when the stream finishes successfully
* @param onError - Called if the stream encounters an error
* @param signal - Optional AbortSignal for cancellation
*/ */
export function streamChatMessage( export function streamChatMessage(
request: ChatRequest, request: ChatRequest,
onChunk: (chunk: string) => void, onChunk: (chunk: string) => void,
onComplete: () => void, onComplete: () => void,
onError: (error: Error) => void onError: (error: Error) => void,
signal?: AbortSignal
): void { ): void {
// Streaming implementation would go here void (async (): Promise<void> => {
void request; try {
void onChunk; const csrfToken = await ensureCsrfTokenForStream();
void onComplete;
void onError; const response = await fetch(`${API_BASE_URL}/api/llm/chat`, {
throw new Error("Streaming not implemented yet"); method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
credentials: "include",
body: JSON.stringify({ ...request, stream: true }),
signal: signal ?? null,
});
if (!response.ok) {
const errorText = await response.text().catch(() => response.statusText);
throw new Error(`Stream request failed: ${errorText}`);
}
if (!response.body) {
throw new Error("Response body is not readable");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let readerDone = false;
while (!readerDone) {
const { done, value } = await reader.read();
readerDone = done;
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
// SSE messages are separated by double newlines
const parts = buffer.split("\n\n");
// Keep the last (potentially incomplete) part
buffer = parts.pop() ?? "";
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed) continue;
for (const line of trimmed.split("\n")) {
if (!line.startsWith("data: ")) continue;
const data = line.slice("data: ".length).trim();
if (data === "[DONE]") {
onComplete();
return;
}
try {
const parsed = JSON.parse(data) as SseChunk;
if (parsed.error) {
throw new Error(parsed.error);
}
if (parsed.message?.content) {
onChunk(parsed.message.content);
}
} catch (parseErr) {
if (parseErr instanceof SyntaxError) {
continue;
}
throw parseErr;
}
}
}
}
// Natural end of stream without [DONE]
onComplete();
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
return;
}
onError(err instanceof Error ? err : new Error(String(err)));
}
})();
} }

View File

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

View File

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

View File

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

View File

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

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

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

Some files were not shown because too many files have changed in this diff Show More