Compare commits

..

54 Commits

Author SHA1 Message Date
a7fbc1ccc8 fix(api): fix lint errors in lazy node-pty import types
All checks were successful
ci/woodpecker/push/api Pipeline was successful
Use proper import type instead of inline import() type annotations
that violate @typescript-eslint/consistent-type-imports rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 07:45:36 -06:00
d18cf44546 fix(api): lazy-load node-pty to prevent API crash when native binary is missing
node-pty requires a compiled native addon (.node binary) that may not
be available in all Docker environments. The eager import crashed the
entire API at startup. Changed to dynamic import() in onModuleInit()
so the service degrades gracefully — terminal sessions are disabled
but all other API functionality works.

Also added explicit node-gyp rebuild to Dockerfile deps stage since
pnpm may skip postinstall scripts for native addons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 07:44:47 -06:00
d05b870f08 fix(api): add build tools for node-pty native compilation in Docker (#524)
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 13:24:34 +00:00
1aaf5618ce docs: close out MS19 Chat & Terminal System mission (#523)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 04:21:38 +00:00
9b2520ce1f feat(web): add agent output terminal tabs for orchestrator sessions (#522)
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-26 04:04:26 +00:00
b110c469c4 feat(web): add orchestrator command system in chat interface (#521)
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-26 03:39:00 +00:00
859dcfc4b7 feat(web): implement multi-session terminal tab management (#520)
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-26 03:18:35 +00:00
13aa52aa53 feat(web): polish master chat with model selector, params config, and empty state (#519)
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-26 03:17:23 +00:00
417c6ab49c feat(web): integrate xterm.js with WebSocket terminal backend (#518)
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 02:55:53 +00:00
8128eb7fbe feat(api): add terminal session persistence with Prisma model and CRUD (#517)
Some checks 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:49:32 +00: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
153 changed files with 23832 additions and 2245 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

@@ -18,6 +18,12 @@ COPY turbo.json ./
# ======================
FROM base AS deps
# Install build tools for native addons (node-pty requires node-gyp compilation)
# and OpenSSL for Prisma engine detection
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ openssl \
&& rm -rf /var/lib/apt/lists/*
# Copy all package.json files for workspace resolution
COPY packages/shared/package.json ./packages/shared/
COPY packages/ui/package.json ./packages/ui/
@@ -25,7 +31,11 @@ COPY packages/config/package.json ./packages/config/
COPY apps/api/package.json ./apps/api/
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
RUN pnpm install --frozen-lockfile
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
# scripts or fail to find prebuilt binaries for this Node.js version
RUN pnpm install --frozen-lockfile \
&& cd node_modules/.pnpm/node-pty@*/node_modules/node-pty \
&& npx node-gyp rebuild 2>&1 || true
# ======================
# Builder stage
@@ -58,7 +68,11 @@ FROM node:24-slim AS production
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
# - openssl: Prisma engine detection requires libssl
# - No build tools needed here — native addons are compiled in the deps stage
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
&& chmod 755 /usr/local/bin/dumb-init \
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs

View File

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

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

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
await prisma.$transaction(async (tx) => {
// 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 { MosaicTelemetryModule } from "./mosaic-telemetry";
import { SpeechModule } from "./speech/speech.module";
import { DashboardModule } from "./dashboard/dashboard.module";
import { TerminalModule } from "./terminal/terminal.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
@Module({
@@ -101,6 +103,8 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
CredentialsModule,
MosaicTelemetryModule,
SpeechModule,
DashboardModule,
TerminalModule,
],
controllers: [AppController, CsrfController],
providers: [

View File

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

View File

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

View File

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

View File

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

View File

@@ -89,20 +89,12 @@ export class CsrfGuard implements CanActivate {
throw new ForbiddenException("CSRF token mismatch");
}
// Validate session binding via HMAC
// Validate session binding via HMAC when user context is available.
// CsrfGuard is a global guard (APP_GUARD) that runs before per-controller
// AuthGuard, so request.user may not be populated yet. In that case, the
// double-submit cookie match above is sufficient CSRF protection.
const userId = request.user?.id;
if (!userId) {
this.logger.warn({
event: "CSRF_NO_USER_CONTEXT",
method: request.method,
path: request.path,
securityEvent: true,
timestamp: new Date().toISOString(),
});
throw new ForbiddenException("CSRF validation requires authentication");
}
if (userId) {
if (!this.csrfService.validateToken(cookieToken, userId)) {
this.logger.warn({
event: "CSRF_SESSION_BINDING_INVALID",
@@ -114,6 +106,14 @@ export class CsrfGuard implements CanActivate {
throw new ForbiddenException("CSRF token not bound to session");
}
} else {
this.logger.debug({
event: "CSRF_SKIP_SESSION_BINDING",
method: request.method,
path: request.path,
reason: "User context not yet available (global guard runs before AuthGuard)",
});
}
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 { FederationMessageStatus } from "@prisma/client";
@Controller("api/v1/federation")
@Controller("v1/federation")
export class CommandController {
private readonly logger = new Logger(CommandController.name);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { RunnerJobsService } from "./runner-jobs.service";
import { PrismaModule } from "../prisma/prisma.module";
import { BullMqModule } from "../bullmq/bullmq.module";
import { AuthModule } from "../auth/auth.module";
import { WebSocketModule } from "../websocket/websocket.module";
/**
* Runner Jobs Module
@@ -12,7 +13,7 @@ import { AuthModule } from "../auth/auth.module";
* for asynchronous job processing.
*/
@Module({
imports: [PrismaModule, BullMqModule, AuthModule],
imports: [PrismaModule, BullMqModule, AuthModule, WebSocketModule],
controllers: [RunnerJobsController],
providers: [RunnerJobsService],
exports: [RunnerJobsService],

View File

@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
import { RunnerJobsService } from "./runner-jobs.service";
import { PrismaService } from "../prisma/prisma.service";
import { BullMqService } from "../bullmq/bullmq.service";
import { WebSocketGateway } from "../websocket/websocket.gateway";
import { RunnerJobStatus } from "@prisma/client";
import { ConflictException, BadRequestException } from "@nestjs/common";
@@ -19,6 +20,12 @@ describe("RunnerJobsService - Concurrency", () => {
getQueue: vi.fn(),
};
const mockWebSocketGateway = {
emitJobCreated: vi.fn(),
emitJobStatusChanged: vi.fn(),
emitJobProgress: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -37,6 +44,10 @@ describe("RunnerJobsService - Concurrency", () => {
provide: BullMqService,
useValue: mockBullMqService,
},
{
provide: WebSocketGateway,
useValue: mockWebSocketGateway,
},
],
}).compile();

View File

@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
import { RunnerJobsService } from "./runner-jobs.service";
import { PrismaService } from "../prisma/prisma.service";
import { BullMqService } from "../bullmq/bullmq.service";
import { WebSocketGateway } from "../websocket/websocket.gateway";
import { RunnerJobStatus } from "@prisma/client";
import { NotFoundException, BadRequestException } from "@nestjs/common";
import { CreateJobDto, QueryJobsDto } from "./dto";
@@ -32,6 +33,12 @@ describe("RunnerJobsService", () => {
getQueue: vi.fn(),
};
const mockWebSocketGateway = {
emitJobCreated: vi.fn(),
emitJobStatusChanged: vi.fn(),
emitJobProgress: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -44,6 +51,10 @@ describe("RunnerJobsService", () => {
provide: BullMqService,
useValue: mockBullMqService,
},
{
provide: WebSocketGateway,
useValue: mockWebSocketGateway,
},
],
}).compile();

View File

@@ -3,6 +3,7 @@ import { Prisma, RunnerJobStatus } from "@prisma/client";
import { Response } from "express";
import { PrismaService } from "../prisma/prisma.service";
import { BullMqService } from "../bullmq/bullmq.service";
import { WebSocketGateway } from "../websocket/websocket.gateway";
import { QUEUE_NAMES } from "../bullmq/queues";
import { ConcurrentUpdateException } from "../common/exceptions/concurrent-update.exception";
import type { CreateJobDto, QueryJobsDto } from "./dto";
@@ -14,7 +15,8 @@ import type { CreateJobDto, QueryJobsDto } from "./dto";
export class RunnerJobsService {
constructor(
private readonly prisma: PrismaService,
private readonly bullMq: BullMqService
private readonly bullMq: BullMqService,
private readonly wsGateway: WebSocketGateway
) {}
/**
@@ -56,6 +58,8 @@ export class RunnerJobsService {
{ priority }
);
this.wsGateway.emitJobCreated(workspaceId, job);
return job;
}
@@ -194,6 +198,13 @@ export class RunnerJobsService {
throw new NotFoundException(`RunnerJob with ID ${id} not found after cancel`);
}
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
id,
workspaceId,
status: job.status,
previousStatus: existingJob.status,
});
return job;
});
}
@@ -248,6 +259,8 @@ export class RunnerJobsService {
{ priority: existingJob.priority }
);
this.wsGateway.emitJobCreated(workspaceId, newJob);
return newJob;
}
@@ -530,6 +543,13 @@ export class RunnerJobsService {
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
}
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
id,
workspaceId,
status: updatedJob.status,
previousStatus: existingJob.status,
});
return updatedJob;
});
}
@@ -606,6 +626,12 @@ export class RunnerJobsService {
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
}
this.wsGateway.emitJobProgress(workspaceId, id, {
id,
workspaceId,
progressPercent: updatedJob.progressPercent,
});
return updatedJob;
});
}

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,339 @@
/**
* 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(async () => {
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();
// Trigger lazy import of node-pty (uses dynamic import(), intercepted by vi.mock)
await service.onModuleInit();
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,276 @@
/**
* 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, OnModuleInit } from "@nestjs/common";
import type { IPty } from "node-pty";
import type { Socket } from "socket.io";
import { randomUUID } from "node:crypto";
// Lazy-loaded in onModuleInit via dynamic import() to prevent crash
// if the native binary is missing. node-pty requires a compiled .node
// binary which may not be available in all Docker environments.
interface NodePtyModule {
spawn: (file: string, args: string[], options: Record<string, unknown>) => IPty;
}
let pty: NodePtyModule | null = null;
/** 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: 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 implements OnModuleInit {
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>>();
async onModuleInit(): Promise<void> {
if (!pty) {
try {
pty = await import("node-pty");
this.logger.log("node-pty loaded successfully — terminal sessions available");
} catch {
this.logger.warn(
"node-pty native module not available — terminal sessions will be disabled. " +
"Install build tools (python3, make, g++) and rebuild node-pty to enable."
);
}
}
}
/**
* 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 or node-pty is unavailable
*/
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
if (!pty) {
throw new Error("Terminal sessions are unavailable: node-pty native module failed to load");
}
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/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -18,15 +18,30 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^9.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hello-pangea/dnd": "^18.0.1",
"@mosaic/shared": "workspace:*",
"@mosaic/ui": "workspace:*",
"@tanstack/react-query": "^5.90.20",
"@tiptap/extension-code-block-lowlight": "^3.20.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0",
"@tiptap/extension-table": "^3.20.0",
"@tiptap/extension-table-cell": "^3.20.0",
"@tiptap/extension-table-header": "^3.20.0",
"@tiptap/extension-table-row": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@types/dompurify": "^3.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.5.3",
"better-auth": "^1.4.17",
"date-fns": "^4.1.0",
"dompurify": "^3.3.1",
"elkjs": "^0.9.3",
"lowlight": "^3.3.0",
"lucide-react": "^0.563.0",
"mermaid": "^11.4.1",
"next": "^16.1.6",
@@ -34,7 +49,8 @@
"react-dom": "^19.0.0",
"react-grid-layout": "^2.2.2",
"recharts": "^3.7.0",
"socket.io-client": "^4.8.3"
"socket.io-client": "^4.8.3",
"tiptap-markdown": "^0.9.0"
},
"devDependencies": {
"@mosaic/config": "workspace:*",

View File

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

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 type { Event } from "@mosaic/shared";
import CalendarPage from "./page";
// Mock the Calendar component
@@ -15,15 +16,94 @@ vi.mock("@/components/calendar/Calendar", () => ({
),
}));
// Mock MosaicSpinner
vi.mock("@/components/ui/MosaicSpinner", () => ({
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
<div data-testid="mosaic-spinner">{label ?? "Loading..."}</div>
),
}));
// Mock useWorkspaceId
const mockUseWorkspaceId = vi.fn<() => string | null>();
vi.mock("@/lib/hooks", () => ({
useWorkspaceId: (): string | null => mockUseWorkspaceId(),
}));
// Mock fetchEvents
const mockFetchEvents = vi.fn<() => Promise<Event[]>>();
vi.mock("@/lib/api/events", () => ({
fetchEvents: (...args: unknown[]): Promise<Event[]> => mockFetchEvents(...(args as [])),
}));
const fakeEvents: Event[] = [
{
id: "event-1",
title: "Team standup",
description: "Daily standup meeting",
startTime: new Date("2026-02-20T09:00:00Z"),
endTime: new Date("2026-02-20T09:30:00Z"),
allDay: false,
location: null,
recurrence: null,
creatorId: "user-1",
projectId: null,
workspaceId: "ws-1",
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "event-2",
title: "Sprint planning",
description: "Bi-weekly sprint planning",
startTime: new Date("2026-02-21T14:00:00Z"),
endTime: new Date("2026-02-21T15:00:00Z"),
allDay: false,
location: null,
recurrence: null,
creatorId: "user-1",
projectId: null,
workspaceId: "ws-1",
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "event-3",
title: "All-day workshop",
description: null,
startTime: new Date("2026-02-22T00:00:00Z"),
endTime: null,
allDay: true,
location: "Conference Room A",
recurrence: null,
creatorId: "user-1",
projectId: null,
workspaceId: "ws-1",
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
];
describe("CalendarPage", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
mockUseWorkspaceId.mockReturnValue("ws-1");
mockFetchEvents.mockResolvedValue(fakeEvents);
});
it("should render the page title", (): void => {
render(<CalendarPage />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Calendar");
});
it("should show loading state initially", (): void => {
// Never resolve so we stay in loading state
// eslint-disable-next-line @typescript-eslint/no-empty-function
mockFetchEvents.mockReturnValue(new Promise<Event[]>(() => {}));
render(<CalendarPage />);
expect(screen.getByTestId("calendar")).toHaveTextContent("Loading");
expect(screen.getByTestId("mosaic-spinner")).toBeInTheDocument();
});
it("should render the Calendar with events after loading", async (): Promise<void> => {
@@ -43,4 +123,31 @@ describe("CalendarPage", (): void => {
render(<CalendarPage />);
expect(screen.getByText("View your schedule at a glance")).toBeInTheDocument();
});
it("should show empty state when no events exist", async (): Promise<void> => {
mockFetchEvents.mockResolvedValue([]);
render(<CalendarPage />);
await waitFor((): void => {
expect(screen.getByText("No events scheduled")).toBeInTheDocument();
});
});
it("should show error state on API failure", async (): Promise<void> => {
mockFetchEvents.mockRejectedValue(new Error("Network error"));
render(<CalendarPage />);
await waitFor((): void => {
expect(screen.getByText("Network error")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument();
});
it("should not fetch when workspace ID is not available", async (): Promise<void> => {
mockUseWorkspaceId.mockReturnValue(null);
render(<CalendarPage />);
// Wait a tick to ensure useEffect ran
await waitFor((): void => {
expect(mockFetchEvents).not.toHaveBeenCalled();
});
});
});

View File

@@ -3,57 +3,161 @@
import { useState, useEffect } from "react";
import type { ReactElement } from "react";
import { Calendar } from "@/components/calendar/Calendar";
import { mockEvents } from "@/lib/api/events";
import { fetchEvents } from "@/lib/api/events";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
import { useWorkspaceId } from "@/lib/hooks";
import type { Event } from "@mosaic/shared";
export default function CalendarPage(): ReactElement {
const workspaceId = useWorkspaceId();
const [events, setEvents] = useState<Event[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void loadEvents();
}, []);
if (!workspaceId) {
setIsLoading(false);
return;
}
const wsId = workspaceId;
let cancelled = false;
setError(null);
setIsLoading(true);
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) {
const data = await fetchEvents(wsId);
if (!cancelled) {
setEvents(data);
}
} catch (err: unknown) {
console.error("[Calendar] Failed to fetch events:", err);
if (!cancelled) {
setError(
err instanceof Error
? err.message
: "We had trouble loading your calendar. Please try again when you're ready."
);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
void loadEvents();
return (): void => {
cancelled = true;
};
}, [workspaceId]);
function handleRetry(): void {
if (!workspaceId) return;
const wsId = workspaceId;
setError(null);
setIsLoading(true);
fetchEvents(wsId)
.then((data) => {
setEvents(data);
})
.catch((err: unknown) => {
console.error("[Calendar] Retry failed:", err);
setError(
err instanceof Error
? err.message
: "We had trouble loading your calendar. Please try again when you're ready."
);
})
.finally(() => {
setIsLoading(false);
});
}
if (isLoading) {
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
Calendar
</h1>
<p style={{ color: "var(--text-muted)" }} className="mt-2">
View your schedule at a glance
</p>
</div>
<div className="flex justify-center py-16">
<MosaicSpinner label="Loading calendar..." />
</div>
</main>
);
}
if (error !== null) {
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
Calendar
</h1>
<p style={{ color: "var(--text-muted)" }} className="mt-2">
View your schedule at a glance
</p>
</div>
<div
className="rounded-lg p-6 text-center"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<p style={{ color: "var(--danger)" }}>{error}</p>
<button
onClick={handleRetry}
className="mt-4 rounded-md px-4 py-2 text-sm font-medium transition-colors"
style={{
background: "var(--accent)",
color: "var(--surface)",
}}
>
Try again
</button>
</div>
</main>
);
}
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Calendar</h1>
<p className="text-gray-600 mt-2">View your schedule at a glance</p>
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
Calendar
</h1>
<p style={{ color: "var(--text-muted)" }} className="mt-2">
View your schedule at a glance
</p>
</div>
{error !== null ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
<p className="text-amber-800">{error}</p>
<button
onClick={() => void loadEvents()}
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
{events.length === 0 ? (
<div
className="rounded-lg p-8 text-center"
style={{
background: "var(--surface)",
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>
) : (
<Calendar events={events} isLoading={isLoading} />
<Calendar events={events} isLoading={false} />
)}
</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 { useState, useMemo } from "react";
import { useState, useEffect, useCallback } from "react";
import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared";
import type { EntryStatus } from "@mosaic/shared";
import { EntryList } from "@/components/knowledge/EntryList";
import { EntryFilters } from "@/components/knowledge/EntryFilters";
import { ImportExportActions } from "@/components/knowledge";
import { mockEntries, mockTags } from "@/lib/api/knowledge";
import { fetchEntries, fetchTags } from "@/lib/api/knowledge";
import type { EntriesResponse } from "@/lib/api/knowledge";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
import Link from "next/link";
import { Plus } from "lucide-react";
export default function KnowledgePage(): ReactElement {
// TODO: Replace with real API call when backend is ready
// const { data: entries, isLoading } = useQuery({
// queryKey: ["knowledge-entries"],
// queryFn: fetchEntries,
// });
const [isLoading] = useState(false);
// Data state
const [entries, setEntries] = useState<KnowledgeEntryWithTags[]>([]);
const [tags, setTags] = useState<KnowledgeTag[]>([]);
const [totalEntries, setTotalEntries] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filter and sort state
const [selectedStatus, setSelectedStatus] = useState<EntryStatus | "all">("all");
@@ -31,60 +33,65 @@ export default function KnowledgePage(): ReactElement {
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
// Client-side filtering and sorting
const filteredAndSortedEntries = useMemo(() => {
let filtered = [...mockEntries];
// Load tags on mount
useEffect(() => {
let cancelled = false;
// Filter by status
if (selectedStatus !== "all") {
filtered = filtered.filter((entry) => entry.status === selectedStatus);
fetchTags()
.then((result) => {
if (!cancelled) {
setTags(result);
}
// Filter by tag
if (selectedTag !== "all") {
filtered = filtered.filter((entry) =>
entry.tags.some((tag: { slug: string }) => tag.slug === selectedTag)
);
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(entry) =>
entry.title.toLowerCase().includes(query) ||
(entry.summary?.toLowerCase().includes(query) ?? false) ||
entry.tags.some((tag: { name: string }): boolean =>
tag.name.toLowerCase().includes(query)
)
);
}
// Sort entries
filtered.sort((a, b) => {
let comparison = 0;
if (sortBy === "title") {
comparison = a.title.localeCompare(b.title);
} else if (sortBy === "createdAt") {
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
} else {
// updatedAt
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
}
return sortOrder === "asc" ? comparison : -comparison;
})
.catch((err: unknown) => {
console.error("Failed to load tags:", err);
});
return filtered;
}, [selectedStatus, selectedTag, searchQuery, sortBy, sortOrder]);
return (): void => {
cancelled = true;
};
}, []);
// Pagination
const totalPages = Math.ceil(filteredAndSortedEntries.length / itemsPerPage);
const paginatedEntries = filteredAndSortedEntries.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
// Load entries when filters/sort/page change
const loadEntries = useCallback(async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const filters: Record<string, unknown> = {
page: currentPage,
limit: itemsPerPage,
sortBy,
sortOrder,
};
if (selectedStatus !== "all") {
filters.status = selectedStatus;
}
if (selectedTag !== "all") {
filters.tag = selectedTag;
}
if (searchQuery.trim()) {
filters.search = searchQuery.trim();
}
const response: EntriesResponse = await fetchEntries(
filters as Parameters<typeof fetchEntries>[0]
);
setEntries(response.data);
setTotalEntries(response.meta?.total ?? response.data.length);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load entries");
} finally {
setIsLoading(false);
}
}, [currentPage, itemsPerPage, sortBy, sortOrder, selectedStatus, selectedTag, searchQuery]);
useEffect(() => {
void loadEntries();
}, [loadEntries]);
const totalPages = Math.max(1, Math.ceil(totalEntries / itemsPerPage));
// Reset to page 1 when filters change
const handleFilterChange = (callback: () => void): void => {
@@ -101,6 +108,16 @@ export default function KnowledgePage(): ReactElement {
setCurrentPage(1);
};
if (isLoading && entries.length === 0) {
return (
<main className="container mx-auto px-4 py-8 max-w-5xl">
<div className="flex justify-center items-center py-20">
<MosaicSpinner size={48} label="Loading knowledge base..." />
</div>
</main>
);
}
return (
<main className="container mx-auto px-4 py-8 max-w-5xl">
{/* Header */}
@@ -125,14 +142,37 @@ export default function KnowledgePage(): ReactElement {
<div className="flex justify-end">
<ImportExportActions
onImportComplete={() => {
// TODO: Refresh the entry list when real API is connected
// For now, this would trigger a refetch of the entries
window.location.reload();
void loadEntries();
}}
/>
</div>
</div>
{/* Error state */}
{error && (
<div
className="mb-6 p-4 rounded-lg border"
style={{
borderColor: "var(--danger)",
background: "rgba(229,72,77,0.08)",
}}
>
<p className="text-sm" style={{ color: "var(--danger)" }}>
{error}
</p>
<button
type="button"
onClick={() => {
void loadEntries();
}}
className="mt-2 text-sm font-medium underline"
style={{ color: "var(--danger)" }}
>
Retry
</button>
</div>
)}
{/* Filters */}
<EntryFilters
selectedStatus={selectedStatus}
@@ -140,7 +180,7 @@ export default function KnowledgePage(): ReactElement {
searchQuery={searchQuery}
sortBy={sortBy}
sortOrder={sortOrder}
tags={mockTags}
tags={tags}
onStatusChange={(status) => {
handleFilterChange(() => {
setSelectedStatus(status);
@@ -161,7 +201,7 @@ export default function KnowledgePage(): ReactElement {
{/* Entry list */}
<EntryList
entries={paginatedEntries}
entries={entries}
isLoading={isLoading}
currentPage={currentPage}
totalPages={totalPages}

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 { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import DashboardPage from "./page";
import * as layoutsApi from "@/lib/api/layouts";
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
// Mock dashboard widgets
vi.mock("@/components/dashboard/RecentTasksWidget", () => ({
RecentTasksWidget: ({
tasks,
isLoading,
// ResizeObserver is not available in jsdom
beforeAll((): void => {
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
});
// Mock WidgetGrid to avoid react-grid-layout dependency in tests
vi.mock("@/components/widgets/WidgetGrid", () => ({
WidgetGrid: ({
layout,
isEditing,
}: {
tasks: unknown[];
isLoading: boolean;
layout: WidgetPlacement[];
isEditing?: boolean;
}): React.JSX.Element => (
<div data-testid="recent-tasks">
{isLoading ? "Loading tasks" : `${String(tasks.length)} tasks`}
<div data-testid="widget-grid" data-editing={isEditing}>
{layout.map((item) => (
<div key={item.i} data-testid={`widget-${item.i}`}>
{item.i}
</div>
))}
</div>
),
}));
vi.mock("@/components/dashboard/UpcomingEventsWidget", () => ({
UpcomingEventsWidget: ({
events,
isLoading,
}: {
events: unknown[];
isLoading: boolean;
}): React.JSX.Element => (
<div data-testid="upcoming-events">
{isLoading ? "Loading events" : `${String(events.length)} events`}
</div>
),
// Mock hooks
vi.mock("@/lib/hooks", () => ({
useWorkspaceId: (): string | null => "ws-test-123",
}));
vi.mock("@/components/dashboard/QuickCaptureWidget", () => ({
QuickCaptureWidget: (): React.JSX.Element => <div data-testid="quick-capture">Quick Capture</div>,
}));
// Mock layout API
vi.mock("@/lib/api/layouts");
vi.mock("@/components/dashboard/DomainOverviewWidget", () => ({
DomainOverviewWidget: ({
tasks,
isLoading,
}: {
tasks: unknown[];
isLoading: boolean;
}): React.JSX.Element => (
<div data-testid="domain-overview">
{isLoading ? "Loading overview" : `${String(tasks.length)} tasks overview`}
</div>
),
}));
const mockExistingLayout: UserLayout = {
id: "layout-1",
workspaceId: "ws-test-123",
userId: "user-1",
name: "Default",
isDefault: true,
layout: [
{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2 },
{ i: "CalendarWidget-default", x: 4, y: 0, w: 4, h: 2 },
],
metadata: {},
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
};
describe("DashboardPage", (): void => {
it("should render the page title", (): void => {
render(<DashboardPage />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Dashboard");
beforeEach((): void => {
vi.clearAllMocks();
});
it("should show loading state initially", (): void => {
render(<DashboardPage />);
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 WidgetGrid with saved layout", async (): Promise<void> => {
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
it("should render all widgets with data after loading", async (): Promise<void> => {
render(<DashboardPage />);
await waitFor((): void => {
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("4 tasks");
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-grid")).toBeInTheDocument();
});
it("should have proper layout structure", (): void => {
const { container } = render(<DashboardPage />);
const main = container.querySelector("main");
expect(main).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 }],
});
it("should render the welcome subtitle", (): void => {
render(<DashboardPage />);
expect(screen.getByText(/Welcome back/)).toBeInTheDocument();
await waitFor((): void => {
expect(layoutsApi.createLayout).toHaveBeenCalledWith("ws-test-123", {
name: "Default",
isDefault: true,
layout: expect.arrayContaining([
expect.objectContaining({ i: "TasksWidget-default" }),
]) as WidgetPlacement[],
});
});
});
it("should show loading spinner initially", (): void => {
// Never-resolving promise to test loading state
vi.mocked(layoutsApi.fetchDefaultLayout).mockReturnValue(
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving
new Promise(() => {})
);
render(<DashboardPage />);
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
});
it("should fall back to default layout on API error", async (): Promise<void> => {
vi.mocked(layoutsApi.fetchDefaultLayout).mockRejectedValue(new Error("Network error"));
render(<DashboardPage />);
await waitFor((): void => {
expect(screen.getByTestId("widget-grid")).toBeInTheDocument();
});
});
it("should render Dashboard heading", async (): Promise<void> => {
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
render(<DashboardPage />);
await waitFor((): void => {
expect(screen.getByText("Dashboard")).toBeInTheDocument();
});
});
it("should render Edit Layout button", async (): Promise<void> => {
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
render(<DashboardPage />);
await waitFor((): void => {
expect(screen.getByText("Edit Layout")).toBeInTheDocument();
});
});
it("should toggle edit mode on button click", async (): Promise<void> => {
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
render(<DashboardPage />);
await waitFor((): void => {
expect(screen.getByText("Edit Layout")).toBeInTheDocument();
});
act((): void => {
screen.getByText("Edit Layout").click();
});
expect(screen.getByText("Done")).toBeInTheDocument();
expect(screen.getByTestId("widget-grid").getAttribute("data-editing")).toBe("true");
});
});

View File

@@ -1,32 +1,242 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import type { ReactElement } from "react";
import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics";
import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions";
import { QuickActions } from "@/components/dashboard/QuickActions";
import { ActivityFeed } from "@/components/dashboard/ActivityFeed";
import { TokenBudget } from "@/components/dashboard/TokenBudget";
import type { WidgetPlacement } from "@mosaic/shared";
import { WidgetGrid } from "@/components/widgets/WidgetGrid";
import { WidgetPicker } from "@/components/widgets/WidgetPicker";
import { WidgetConfigDialog } from "@/components/widgets/WidgetConfigDialog";
import { DEFAULT_LAYOUT } from "@/components/widgets/defaultLayout";
import { fetchDefaultLayout, createLayout, updateLayout } from "@/lib/api/layouts";
import { useWorkspaceId } from "@/lib/hooks";
export default function DashboardPage(): ReactElement {
const workspaceId = useWorkspaceId();
const [layout, setLayout] = useState<WidgetPlacement[]>(DEFAULT_LAYOUT);
const [layoutId, setLayoutId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isPickerOpen, setIsPickerOpen] = useState(false);
const [configWidgetId, setConfigWidgetId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Debounce timer for auto-saving layout changes
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load the user's default layout (or create one)
useEffect(() => {
if (!workspaceId) {
setIsLoading(false);
return;
}
const wsId = workspaceId;
const ac = new AbortController();
async function loadLayout(): Promise<void> {
try {
const existing = await fetchDefaultLayout(wsId);
if (ac.signal.aborted) return;
if (existing) {
setLayout(existing.layout);
setLayoutId(existing.id);
} else {
const created = await createLayout(wsId, {
name: "Default",
isDefault: true,
layout: DEFAULT_LAYOUT,
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- aborted can change during await
if (ac.signal.aborted) return;
setLayout(created.layout);
setLayoutId(created.id);
}
} catch (err: unknown) {
console.error("[Dashboard] Failed to load layout:", err);
}
setIsLoading(false);
}
void loadLayout();
return (): void => {
ac.abort();
};
}, [workspaceId]);
// Save layout changes with debounce
const saveLayout = useCallback(
(newLayout: WidgetPlacement[]) => {
if (!workspaceId || !layoutId) return;
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = setTimeout(() => {
void updateLayout(workspaceId, layoutId, { layout: newLayout }).catch((err: unknown) => {
console.error("[Dashboard] Failed to save layout:", err);
});
}, 800);
},
[workspaceId, layoutId]
);
const handleLayoutChange = useCallback(
(newLayout: WidgetPlacement[]) => {
setLayout(newLayout);
saveLayout(newLayout);
},
[saveLayout]
);
const handleRemoveWidget = useCallback(
(widgetId: string) => {
const updated = layout.filter((item) => item.i !== widgetId);
setLayout(updated);
saveLayout(updated);
},
[layout, saveLayout]
);
const handleAddWidget = useCallback(
(placement: WidgetPlacement) => {
const updated = [...layout, placement];
setLayout(updated);
saveLayout(updated);
},
[layout, saveLayout]
);
const handleResetLayout = useCallback((): void => {
setLayout(DEFAULT_LAYOUT);
saveLayout(DEFAULT_LAYOUT);
}, [saveLayout]);
const handleEditWidget = useCallback((widgetId: string): void => {
setConfigWidgetId(widgetId);
}, []);
if (isLoading) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<DashboardMetrics />
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
<div className="flex flex-col items-center gap-2">
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 320px",
gap: 16,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
<OrchestratorSessions />
<QuickActions />
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<ActivityFeed />
<TokenBudget />
</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>
);
}
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>
);
}

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 userEvent from "@testing-library/user-event";
import type { Task } from "@mosaic/shared";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
import TasksPage from "./page";
// Mock the TaskList component
@@ -9,21 +12,121 @@ vi.mock("@/components/tasks/TaskList", () => ({
),
}));
// Mock MosaicSpinner
vi.mock("@/components/ui/MosaicSpinner", () => ({
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
<div data-testid="mosaic-spinner">{label ?? "Loading..."}</div>
),
}));
// Mock useWorkspaceId
const mockUseWorkspaceId = vi.fn<() => string | null>();
vi.mock("@/lib/hooks", () => ({
useWorkspaceId: (): string | null => mockUseWorkspaceId(),
}));
// Mock fetchTasks
const mockFetchTasks = vi.fn<() => Promise<Task[]>>();
vi.mock("@/lib/api/tasks", () => ({
fetchTasks: (...args: unknown[]): Promise<Task[]> => mockFetchTasks(...(args as [])),
}));
const fakeTasks: Task[] = [
{
id: "task-1",
title: "Test task 1",
description: "Description 1",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.HIGH,
dueDate: new Date("2026-02-01"),
creatorId: "user-1",
assigneeId: "user-1",
workspaceId: "ws-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "task-2",
title: "Test task 2",
description: "Description 2",
status: TaskStatus.NOT_STARTED,
priority: TaskPriority.MEDIUM,
dueDate: new Date("2026-02-02"),
creatorId: "user-1",
assigneeId: "user-1",
workspaceId: "ws-1",
projectId: null,
parentId: null,
sortOrder: 1,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
];
describe("TasksPage", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
mockUseWorkspaceId.mockReturnValue("ws-1");
mockFetchTasks.mockResolvedValue(fakeTasks);
});
it("should render the page title", (): void => {
render(<TasksPage />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks");
});
it("should show loading state initially", (): void => {
it("should show loading spinner initially", (): void => {
// Never resolve so we stay in loading state
// eslint-disable-next-line @typescript-eslint/no-empty-function
mockFetchTasks.mockReturnValue(new Promise<Task[]>(() => {}));
render(<TasksPage />);
expect(screen.getByTestId("task-list")).toHaveTextContent("Loading");
expect(screen.getByTestId("mosaic-spinner")).toBeInTheDocument();
});
it("should render the TaskList with tasks after loading", async (): Promise<void> => {
render(<TasksPage />);
await waitFor((): void => {
expect(screen.getByTestId("task-list")).toHaveTextContent("4 tasks");
expect(screen.getByTestId("task-list")).toHaveTextContent("2 tasks");
});
});
it("should show empty state when no tasks exist", async (): Promise<void> => {
mockFetchTasks.mockResolvedValue([]);
render(<TasksPage />);
await waitFor((): void => {
expect(screen.getByText("No tasks found")).toBeInTheDocument();
});
});
it("should show error state on API failure", async (): Promise<void> => {
mockFetchTasks.mockRejectedValue(new Error("Network error"));
render(<TasksPage />);
await waitFor((): void => {
expect(screen.getByText("Network error")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument();
});
it("should retry fetching on retry button click", async (): Promise<void> => {
mockFetchTasks.mockRejectedValueOnce(new Error("Network error"));
render(<TasksPage />);
await waitFor((): void => {
expect(screen.getByText("Network error")).toBeInTheDocument();
});
mockFetchTasks.mockResolvedValueOnce(fakeTasks);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /try again/i }));
await waitFor((): void => {
expect(screen.getByTestId("task-list")).toHaveTextContent("2 tasks");
});
});
@@ -37,4 +140,14 @@ describe("TasksPage", (): void => {
render(<TasksPage />);
expect(screen.getByText("Organize your work at your own pace")).toBeInTheDocument();
});
it("should not fetch when workspace ID is not available", async (): Promise<void> => {
mockUseWorkspaceId.mockReturnValue(null);
render(<TasksPage />);
// Wait a tick to ensure useEffect ran
await waitFor((): void => {
expect(mockFetchTasks).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,57 +4,123 @@ import { useState, useEffect } from "react";
import type { ReactElement } from "react";
import { TaskList } from "@/components/tasks/TaskList";
import { mockTasks } from "@/lib/api/tasks";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
import { fetchTasks } from "@/lib/api/tasks";
import { useWorkspaceId } from "@/lib/hooks";
import type { Task } from "@mosaic/shared";
export default function TasksPage(): ReactElement {
const workspaceId = useWorkspaceId();
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void loadTasks();
}, []);
if (!workspaceId) {
setIsLoading(false);
return;
}
let cancelled = false;
setError(null);
setIsLoading(true);
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) {
const filters = workspaceId !== null ? { workspaceId } : {};
const data = await fetchTasks(filters);
if (!cancelled) {
setTasks(data);
}
} catch (err: unknown) {
console.error("[Tasks] Failed to fetch tasks:", err);
if (!cancelled) {
setError(
err instanceof Error
? err.message
: "We had trouble loading your tasks. Please try again when you're ready."
);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
void loadTasks();
return (): void => {
cancelled = true;
};
}, [workspaceId]);
function handleRetry(): void {
if (!workspaceId) return;
setError(null);
setIsLoading(true);
fetchTasks({ workspaceId })
.then((data) => {
setTasks(data);
})
.catch((err: unknown) => {
console.error("[Tasks] Retry failed:", err);
setError(
err instanceof Error
? err.message
: "We had trouble loading your tasks. Please try again when you're ready."
);
})
.finally(() => {
setIsLoading(false);
});
}
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Tasks</h1>
<p className="text-gray-600 mt-2">Organize your work at your own pace</p>
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
Tasks
</h1>
<p className="mt-2" style={{ color: "var(--text-muted)" }}>
Organize your work at your own pace
</p>
</div>
{error !== null ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
<p className="text-amber-800">{error}</p>
{isLoading ? (
<div className="flex justify-center py-16">
<MosaicSpinner label="Loading tasks..." />
</div>
) : error !== null ? (
<div
className="rounded-lg p-6 text-center"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<p style={{ color: "var(--danger)" }}>{error}</p>
<button
onClick={() => void loadTasks()}
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
onClick={handleRetry}
className="mt-4 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
style={{ background: "var(--danger)" }}
>
Try again
</button>
</div>
) : tasks.length === 0 ? (
<div
className="rounded-lg p-8 text-center"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<p style={{ color: "var(--text-muted)" }}>No tasks found</p>
</div>
) : (
<TaskList tasks={tasks} isLoading={isLoading} />
<TaskList tasks={tasks} isLoading={false} />
)}
</main>
);

File diff suppressed because it is too large Load Diff

View File

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

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

@@ -4,12 +4,13 @@
*/
import { createRef } from "react";
import { render } from "@testing-library/react";
import { render, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest";
import { Chat, type ChatRef } from "./Chat";
import * as useChatModule from "@/hooks/useChat";
import * as useWebSocketModule from "@/hooks/useWebSocket";
import * as authModule from "@/lib/auth/auth-context";
import * as orchestratorModule from "@/hooks/useOrchestratorCommands";
// Mock scrollIntoView (not available in JSDOM)
Element.prototype.scrollIntoView = vi.fn();
@@ -39,10 +40,28 @@ vi.mock("./ChatInput", () => ({
disabled: boolean;
inputRef: React.RefObject<HTMLTextAreaElement | null>;
}): React.ReactElement => (
<>
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
Send
</button>
<button data-testid="chat-input-command" onClick={(): void => void onSend("/status")}>
Send Command
</button>
</>
),
DEFAULT_TEMPERATURE: 0.7,
DEFAULT_MAX_TOKENS: 4096,
DEFAULT_MODEL: "llama3.2",
AVAILABLE_MODELS: [
{ id: "llama3.2", label: "Llama 3.2" },
{ id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" },
{ id: "gpt-4o", label: "GPT-4o" },
{ id: "deepseek-r1", label: "DeepSeek R1" },
],
}));
vi.mock("@/hooks/useOrchestratorCommands", () => ({
useOrchestratorCommands: vi.fn(),
}));
const mockUseAuth = authModule.useAuth as MockedFunction<typeof authModule.useAuth>;
@@ -50,6 +69,9 @@ const mockUseChat = useChatModule.useChat as MockedFunction<typeof useChatModule
const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction<
typeof useWebSocketModule.useWebSocket
>;
const mockUseOrchestratorCommands = orchestratorModule.useOrchestratorCommands as MockedFunction<
typeof orchestratorModule.useOrchestratorCommands
>;
function createMockUseChatReturn(
overrides: Partial<useChatModule.UseChatReturn> = {}
@@ -64,10 +86,12 @@ function createMockUseChatReturn(
},
],
isLoading: false,
isStreaming: false,
error: null,
conversationId: null,
conversationTitle: null,
sendMessage: vi.fn().mockResolvedValue(undefined),
abortStream: vi.fn(),
loadConversation: vi.fn().mockResolvedValue(undefined),
startNewConversation: vi.fn(),
setMessages: vi.fn(),
@@ -96,6 +120,12 @@ describe("Chat", () => {
socket: null,
connectionError: null,
});
// Default: no commands intercepted
mockUseOrchestratorCommands.mockReturnValue({
isCommand: vi.fn().mockReturnValue(false),
executeCommand: vi.fn().mockResolvedValue(null),
});
});
afterEach(() => {
@@ -149,4 +179,105 @@ describe("Chat", () => {
});
});
});
describe("orchestrator command routing", () => {
it("routes command messages through orchestrator instead of LLM", async () => {
const mockSendMessage = vi.fn().mockResolvedValue(undefined);
const mockSetMessages = vi.fn();
const mockExecuteCommand = vi.fn().mockResolvedValue({
id: "orch-123",
role: "assistant" as const,
content: "**Orchestrator Status**\n\n| Field | Value |\n|---|---|\n| Status | **Ready** |",
createdAt: new Date().toISOString(),
});
mockUseChat.mockReturnValue(
createMockUseChatReturn({
sendMessage: mockSendMessage,
setMessages: mockSetMessages,
})
);
mockUseOrchestratorCommands.mockReturnValue({
isCommand: (content: string) => content.trim().startsWith("/"),
executeCommand: mockExecuteCommand,
});
const { getByTestId } = render(<Chat />);
const commandButton = getByTestId("chat-input-command");
fireEvent.click(commandButton);
await waitFor(() => {
// executeCommand should have been called with the slash command
expect(mockExecuteCommand).toHaveBeenCalledWith("/status");
});
// sendMessage should NOT have been called
expect(mockSendMessage).not.toHaveBeenCalled();
// setMessages should have been called to add user and assistant messages
await waitFor(() => {
expect(mockSetMessages).toHaveBeenCalledTimes(2);
});
});
it("does not call orchestrator for regular messages", async () => {
const mockSendMessage = vi.fn().mockResolvedValue(undefined);
const mockExecuteCommand = vi.fn().mockResolvedValue(null);
mockUseChat.mockReturnValue(createMockUseChatReturn({ sendMessage: mockSendMessage }));
mockUseOrchestratorCommands.mockReturnValue({
isCommand: vi.fn().mockReturnValue(false),
executeCommand: mockExecuteCommand,
});
const { getByTestId } = render(<Chat />);
fireEvent.click(getByTestId("chat-input"));
await waitFor(() => {
expect(mockSendMessage).toHaveBeenCalledWith("test message");
});
expect(mockExecuteCommand).not.toHaveBeenCalled();
});
it("still adds user message to chat for commands", async () => {
const mockSetMessages = vi.fn();
const mockExecuteCommand = vi.fn().mockResolvedValue({
id: "orch-456",
role: "assistant" as const,
content: "Help content",
createdAt: new Date().toISOString(),
});
mockUseChat.mockReturnValue(createMockUseChatReturn({ setMessages: mockSetMessages }));
mockUseOrchestratorCommands.mockReturnValue({
isCommand: (content: string) => content.trim().startsWith("/"),
executeCommand: mockExecuteCommand,
});
const { getByTestId } = render(<Chat />);
fireEvent.click(getByTestId("chat-input-command"));
await waitFor(() => {
expect(mockSetMessages).toHaveBeenCalled();
});
// First setMessages call should add the user message
const firstCall = mockSetMessages.mock.calls[0];
if (!firstCall) throw new Error("Expected setMessages to have been called");
const updater = firstCall[0] as (prev: useChatModule.Message[]) => useChatModule.Message[];
const result = updater([]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
role: "user",
content: "/status",
});
});
});
});

View File

@@ -3,9 +3,11 @@
import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
import { useAuth } from "@/lib/auth/auth-context";
import { useChat } from "@/hooks/useChat";
import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands";
import { useWebSocket } from "@/hooks/useWebSocket";
import { MessageList } from "./MessageList";
import { ChatInput } from "./ChatInput";
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
import { ChatEmptyState } from "./ChatEmptyState";
import type { Message } from "@/hooks/useChat";
export interface ChatRef {
@@ -59,31 +61,37 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
const { user, isLoading: authLoading } = useAuth();
// Use the chat hook for state management
// Model and params state — initialized from ChatInput's persisted values
const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2");
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
// Suggestion fill value: controls ChatInput's textarea content
const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined);
const {
messages,
isLoading: isChatLoading,
isStreaming,
error,
conversationId,
conversationTitle,
sendMessage,
abortStream,
loadConversation,
startNewConversation,
setMessages,
clearError,
} = useChat({
model: "llama3.2",
model: selectedModel,
temperature,
maxTokens,
...(initialProjectId !== undefined && { projectId: initialProjectId }),
});
// Connect to WebSocket for real-time updates (when we have a user)
const { isConnected: isWsConnected } = useWebSocket(
user?.id ?? "", // Use user ID as workspace ID for now
"", // Token not needed since we use cookies
{
// Future: Add handlers for chat-related events
// onChatMessage: (msg) => { ... }
}
);
const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
const { isCommand, executeCommand } = useOrchestratorCommands();
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -91,7 +99,15 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
const quipTimerRef = useRef<NodeJS.Timeout | null>(null);
const quipIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Expose methods to parent via ref
// Identify the streaming message (last assistant message while streaming)
const streamingMessageId =
isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined;
// Whether the conversation is empty (only welcome message or no messages)
const isEmptyConversation =
messages.length === 0 ||
(messages.length === 1 && messages[0]?.id === "welcome" && !isChatLoading && !isStreaming);
useImperativeHandle(ref, () => ({
loadConversation: async (cId: string): Promise<void> => {
await loadConversation(cId);
@@ -110,7 +126,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
scrollToBottom();
}, [messages, scrollToBottom]);
// Notify parent of conversation changes
useEffect(() => {
if (conversationId && conversationTitle) {
onConversationChange?.(conversationId, {
@@ -125,34 +140,43 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
}
}, [conversationId, conversationTitle, initialProjectId, onConversationChange]);
// Global keyboard shortcut: Ctrl+/ to focus input
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
// Cmd/Ctrl + / : Focus input
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
e.preventDefault();
inputRef.current?.focus();
}
// Cmd/Ctrl + N : Start new conversation
if ((e.ctrlKey || e.metaKey) && (e.key === "n" || e.key === "N")) {
e.preventDefault();
startNewConversation(null);
inputRef.current?.focus();
}
// Cmd/Ctrl + L : Clear / start new conversation
if ((e.ctrlKey || e.metaKey) && (e.key === "l" || e.key === "L")) {
e.preventDefault();
startNewConversation(null);
inputRef.current?.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return (): void => {
document.removeEventListener("keydown", handleKeyDown);
};
}, []);
}, [startNewConversation]);
// Show loading quips
// Show loading quips only during non-streaming load (initial fetch wait)
useEffect(() => {
if (isChatLoading) {
// Show first quip after 3 seconds
if (isChatLoading && !isStreaming) {
quipTimerRef.current = setTimeout(() => {
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
}, 3000);
// Change quip every 5 seconds
quipIntervalRef.current = setInterval(() => {
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
}, 5000);
} else {
// Clear timers when loading stops
if (quipTimerRef.current) {
clearTimeout(quipTimerRef.current);
quipTimerRef.current = null;
@@ -168,16 +192,41 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
if (quipTimerRef.current) clearTimeout(quipTimerRef.current);
if (quipIntervalRef.current) clearInterval(quipIntervalRef.current);
};
}, [isChatLoading]);
}, [isChatLoading, isStreaming]);
const handleSendMessage = useCallback(
async (content: string) => {
if (isCommand(content)) {
// Add user message immediately
const userMessage: Message = {
id: `user-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`,
role: "user",
content: content.trim(),
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
// Execute orchestrator command
const result = await executeCommand(content);
if (result) {
setMessages((prev) => [...prev, result]);
}
return;
}
await sendMessage(content);
},
[sendMessage]
[isCommand, executeCommand, setMessages, sendMessage]
);
// Show loading state while auth is loading
const handleSuggestionClick = useCallback((prompt: string): void => {
setSuggestionValue(prompt);
// Clear after a tick so input receives it, then focus
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}, []);
if (authLoading) {
return (
<div
@@ -224,11 +273,17 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
{/* Messages Area */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-4xl px-4 py-6 lg:px-8">
{isEmptyConversation ? (
<ChatEmptyState onSuggestionClick={handleSuggestionClick} />
) : (
<MessageList
messages={messages as (Message & { thinking?: string })[]}
isLoading={isChatLoading}
isStreaming={isStreaming}
{...(streamingMessageId != null ? { streamingMessageId } : {})}
loadingQuip={loadingQuip}
/>
)}
<div ref={messagesEndRef} />
</div>
</div>
@@ -294,6 +349,12 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
onSend={handleSendMessage}
disabled={isChatLoading || !user}
inputRef={inputRef}
isStreaming={isStreaming}
onStopStreaming={abortStream}
onModelChange={setSelectedModel}
onTemperatureChange={setTemperature}
onMaxTokensChange={setMaxTokens}
{...(suggestionValue !== undefined ? { externalValue: suggestionValue } : {})}
/>
</div>
</div>

View File

@@ -0,0 +1,103 @@
/**
* @file ChatEmptyState.test.tsx
* @description Tests for ChatEmptyState component: greeting, suggestions, click handling
*/
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { ChatEmptyState } from "./ChatEmptyState";
describe("ChatEmptyState", () => {
it("should render the greeting heading", () => {
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
expect(screen.getByRole("heading", { name: /how can i help/i })).toBeDefined();
});
it("should render the empty state container", () => {
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
expect(screen.getByTestId("chat-empty-state")).toBeDefined();
});
it("should render four suggestion buttons", () => {
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
// Four suggestions
const buttons = screen.getAllByRole("button");
expect(buttons.length).toBe(4);
});
it("should render 'Explain this project' suggestion", () => {
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
expect(screen.getByText("Explain this project")).toBeDefined();
});
it("should render 'Help me debug' suggestion", () => {
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
expect(screen.getByText("Help me debug")).toBeDefined();
});
it("should render 'Write a test for' suggestion", () => {
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
expect(screen.getByText("Write a test for")).toBeDefined();
});
it("should render 'Refactor this code' suggestion", () => {
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
expect(screen.getByText("Refactor this code")).toBeDefined();
});
it("should call onSuggestionClick with the correct prompt when a suggestion is clicked", () => {
const onSuggestionClick = vi.fn();
render(<ChatEmptyState onSuggestionClick={onSuggestionClick} />);
const explainButton = screen.getByTestId("suggestion-explain-this-project");
fireEvent.click(explainButton);
expect(onSuggestionClick).toHaveBeenCalledOnce();
const [calledWith] = onSuggestionClick.mock.calls[0] as [string];
expect(calledWith).toContain("overview of this project");
});
it("should call onSuggestionClick for 'Help me debug' prompt", () => {
const onSuggestionClick = vi.fn();
render(<ChatEmptyState onSuggestionClick={onSuggestionClick} />);
const debugButton = screen.getByTestId("suggestion-help-me-debug");
fireEvent.click(debugButton);
expect(onSuggestionClick).toHaveBeenCalledOnce();
const [calledWith] = onSuggestionClick.mock.calls[0] as [string];
expect(calledWith).toContain("debugging");
});
it("should call onSuggestionClick for 'Write a test for' prompt", () => {
const onSuggestionClick = vi.fn();
render(<ChatEmptyState onSuggestionClick={onSuggestionClick} />);
const testButton = screen.getByTestId("suggestion-write-a-test-for");
fireEvent.click(testButton);
expect(onSuggestionClick).toHaveBeenCalledOnce();
});
it("should call onSuggestionClick for 'Refactor this code' prompt", () => {
const onSuggestionClick = vi.fn();
render(<ChatEmptyState onSuggestionClick={onSuggestionClick} />);
const refactorButton = screen.getByTestId("suggestion-refactor-this-code");
fireEvent.click(refactorButton);
expect(onSuggestionClick).toHaveBeenCalledOnce();
const [calledWith] = onSuggestionClick.mock.calls[0] as [string];
expect(calledWith).toContain("refactor");
});
it("should have accessible aria-label on each suggestion button", () => {
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
const buttons = screen.getAllByRole("button");
for (const button of buttons) {
const label = button.getAttribute("aria-label");
expect(label).toBeTruthy();
expect(label).toMatch(/suggestion:/i);
}
});
});

View File

@@ -0,0 +1,99 @@
"use client";
interface Suggestion {
label: string;
prompt: string;
}
const SUGGESTIONS: Suggestion[] = [
{
label: "Explain this project",
prompt: "Can you give me an overview of this project and its key components?",
},
{
label: "Help me debug",
prompt: "I have a bug I need help debugging. Can you walk me through the process?",
},
{
label: "Write a test for",
prompt: "Can you help me write a test for the following function or component?",
},
{
label: "Refactor this code",
prompt: "I have some code I'd like to refactor for better readability and maintainability.",
},
];
interface ChatEmptyStateProps {
onSuggestionClick: (prompt: string) => void;
}
export function ChatEmptyState({ onSuggestionClick }: ChatEmptyStateProps): React.JSX.Element {
return (
<div
className="flex flex-col items-center justify-center gap-6 py-12 px-4 text-center"
data-testid="chat-empty-state"
>
{/* Icon */}
<div
className="flex h-16 w-16 items-center justify-center rounded-2xl"
style={{ backgroundColor: "rgb(var(--accent-primary) / 0.12)" }}
>
<svg
className="h-8 w-8"
style={{ color: "rgb(var(--accent-primary))" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
{/* Greeting */}
<div className="space-y-1">
<h3 className="text-lg font-semibold" style={{ color: "rgb(var(--text-primary))" }}>
How can I help you today?
</h3>
<p className="text-sm max-w-sm" style={{ color: "rgb(var(--text-secondary))" }}>
Ask me anything I can help with code, explanations, debugging, and more.
</p>
</div>
{/* Suggestions */}
<div className="grid grid-cols-1 gap-2 w-full max-w-sm sm:grid-cols-2">
{SUGGESTIONS.map((suggestion) => (
<button
key={suggestion.label}
onClick={() => {
onSuggestionClick(suggestion.prompt);
}}
className="rounded-lg border px-3 py-2.5 text-left text-sm transition-all duration-150 hover:shadow-sm focus:outline-none focus:ring-2"
style={{
backgroundColor: "rgb(var(--surface-1))",
borderColor: "rgb(var(--border-default))",
color: "rgb(var(--text-secondary))",
}}
aria-label={`Suggestion: ${suggestion.label}`}
data-testid={`suggestion-${suggestion.label.toLowerCase().replace(/\s+/g, "-")}`}
>
<span
className="block text-xs font-medium mb-0.5"
style={{ color: "rgb(var(--text-primary))" }}
>
{suggestion.label}
</span>
<span
className="block text-xs leading-relaxed line-clamp-2"
style={{ color: "rgb(var(--text-muted))" }}
>
{suggestion.prompt}
</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,486 @@
/**
* @file ChatInput.test.tsx
* @description Tests for ChatInput: model selector, temperature/params, localStorage persistence,
* and command autocomplete.
*/
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
ChatInput,
AVAILABLE_MODELS,
DEFAULT_MODEL,
DEFAULT_TEMPERATURE,
DEFAULT_MAX_TOKENS,
} from "./ChatInput";
// Mock fetch for version.json
beforeEach(() => {
global.fetch = vi.fn().mockRejectedValue(new Error("Not found"));
});
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
/** Get the first non-default model from the list */
function getNonDefaultModel(): (typeof AVAILABLE_MODELS)[number] {
const model = AVAILABLE_MODELS.find((m) => m.id !== DEFAULT_MODEL);
if (!model) throw new Error("No non-default model found");
return model;
}
describe("ChatInput — model selector", () => {
it("should render the model selector chip showing the default model", () => {
render(<ChatInput onSend={vi.fn()} />);
const defaultLabel =
AVAILABLE_MODELS.find((m) => m.id === DEFAULT_MODEL)?.label ?? DEFAULT_MODEL;
expect(screen.getByText(defaultLabel)).toBeDefined();
});
it("should open the model dropdown when the chip is clicked", () => {
render(<ChatInput onSend={vi.fn()} />);
const chip = screen.getByLabelText(/model:/i);
fireEvent.click(chip);
// The dropdown (listbox role) should be visible
const listbox = screen.getByRole("listbox", { name: /available models/i });
expect(listbox).toBeDefined();
// All model options should appear in the dropdown
const options = within(listbox).getAllByRole("option");
expect(options.length).toBe(AVAILABLE_MODELS.length);
});
it("should call onModelChange when a model is selected", async () => {
const onModelChange = vi.fn();
render(<ChatInput onSend={vi.fn()} onModelChange={onModelChange} />);
const chip = screen.getByLabelText(/model:/i);
fireEvent.click(chip);
const targetModel = getNonDefaultModel();
const listbox = screen.getByRole("listbox", { name: /available models/i });
const targetOption = within(listbox).getByText(targetModel.label);
fireEvent.click(targetOption);
await waitFor(() => {
const calls = onModelChange.mock.calls.map((c: unknown[]) => c[0]);
expect(calls).toContain(targetModel.id);
});
});
it("should persist the selected model in localStorage", async () => {
render(<ChatInput onSend={vi.fn()} />);
const chip = screen.getByLabelText(/model:/i);
fireEvent.click(chip);
const targetModel = getNonDefaultModel();
const listbox = screen.getByRole("listbox", { name: /available models/i });
const targetOption = within(listbox).getByText(targetModel.label);
fireEvent.click(targetOption);
await waitFor(() => {
expect(localStorage.getItem("chat:selectedModel")).toBe(targetModel.id);
});
});
it("should restore the model from localStorage on mount", async () => {
const targetModel = getNonDefaultModel();
localStorage.setItem("chat:selectedModel", targetModel.id);
render(<ChatInput onSend={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText(targetModel.label)).toBeDefined();
});
});
it("should close the dropdown after selecting a model", async () => {
render(<ChatInput onSend={vi.fn()} />);
const chip = screen.getByLabelText(/model:/i);
fireEvent.click(chip);
const targetModel = getNonDefaultModel();
const listbox = screen.getByRole("listbox", { name: /available models/i });
const targetOption = within(listbox).getByText(targetModel.label);
fireEvent.click(targetOption);
// After selection, dropdown should close
await waitFor(() => {
expect(screen.queryByRole("listbox")).toBeNull();
});
});
it("should have aria-expanded on the model chip button", () => {
render(<ChatInput onSend={vi.fn()} />);
const chip = screen.getByLabelText(/model:/i);
expect(chip.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(chip);
expect(chip.getAttribute("aria-expanded")).toBe("true");
});
});
describe("ChatInput — temperature and max tokens", () => {
it("should render the settings/params button", () => {
render(<ChatInput onSend={vi.fn()} />);
const settingsBtn = screen.getByLabelText(/chat parameters/i);
expect(settingsBtn).toBeDefined();
});
it("should open the params popover when settings button is clicked", () => {
render(<ChatInput onSend={vi.fn()} />);
const settingsBtn = screen.getByLabelText(/chat parameters/i);
fireEvent.click(settingsBtn);
expect(screen.getByLabelText(/temperature/i)).toBeDefined();
expect(screen.getByLabelText(/maximum tokens/i)).toBeDefined();
});
it("should show the default temperature value", () => {
render(<ChatInput onSend={vi.fn()} />);
fireEvent.click(screen.getByLabelText(/chat parameters/i));
const slider = screen.getByLabelText(/temperature/i);
expect(parseFloat((slider as HTMLInputElement).value)).toBeCloseTo(DEFAULT_TEMPERATURE);
});
it("should call onTemperatureChange when the slider is moved", async () => {
const onTemperatureChange = vi.fn();
render(<ChatInput onSend={vi.fn()} onTemperatureChange={onTemperatureChange} />);
fireEvent.click(screen.getByLabelText(/chat parameters/i));
const slider = screen.getByLabelText(/temperature/i);
fireEvent.change(slider, { target: { value: "1.2" } });
await waitFor(() => {
const calls = onTemperatureChange.mock.calls.map((c: unknown[]) => c[0]);
expect(calls).toContain(1.2);
});
});
it("should persist temperature in localStorage", async () => {
render(<ChatInput onSend={vi.fn()} />);
fireEvent.click(screen.getByLabelText(/chat parameters/i));
const slider = screen.getByLabelText(/temperature/i);
fireEvent.change(slider, { target: { value: "0.5" } });
await waitFor(() => {
expect(localStorage.getItem("chat:temperature")).toBe("0.5");
});
});
it("should restore temperature from localStorage on mount", async () => {
localStorage.setItem("chat:temperature", "1.5");
const onTemperatureChange = vi.fn();
render(<ChatInput onSend={vi.fn()} onTemperatureChange={onTemperatureChange} />);
await waitFor(() => {
const calls = onTemperatureChange.mock.calls.map((c: unknown[]) => c[0]);
expect(calls).toContain(1.5);
});
});
it("should show the default max tokens value", () => {
render(<ChatInput onSend={vi.fn()} />);
fireEvent.click(screen.getByLabelText(/chat parameters/i));
const input = screen.getByLabelText(/maximum tokens/i);
expect(parseInt((input as HTMLInputElement).value, 10)).toBe(DEFAULT_MAX_TOKENS);
});
it("should call onMaxTokensChange when the max tokens input changes", async () => {
const onMaxTokensChange = vi.fn();
render(<ChatInput onSend={vi.fn()} onMaxTokensChange={onMaxTokensChange} />);
fireEvent.click(screen.getByLabelText(/chat parameters/i));
const input = screen.getByLabelText(/maximum tokens/i);
fireEvent.change(input, { target: { value: "8192" } });
await waitFor(() => {
const calls = onMaxTokensChange.mock.calls.map((c: unknown[]) => c[0]);
expect(calls).toContain(8192);
});
});
it("should persist max tokens in localStorage", async () => {
render(<ChatInput onSend={vi.fn()} />);
fireEvent.click(screen.getByLabelText(/chat parameters/i));
const input = screen.getByLabelText(/maximum tokens/i);
fireEvent.change(input, { target: { value: "2000" } });
await waitFor(() => {
expect(localStorage.getItem("chat:maxTokens")).toBe("2000");
});
});
it("should restore max tokens from localStorage on mount", async () => {
localStorage.setItem("chat:maxTokens", "8000");
const onMaxTokensChange = vi.fn();
render(<ChatInput onSend={vi.fn()} onMaxTokensChange={onMaxTokensChange} />);
await waitFor(() => {
const calls = onMaxTokensChange.mock.calls.map((c: unknown[]) => c[0]);
expect(calls).toContain(8000);
});
});
});
describe("ChatInput — externalValue (suggestion fill)", () => {
it("should update the textarea when externalValue is provided", async () => {
const { rerender } = render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
expect((textarea as HTMLTextAreaElement).value).toBe("");
rerender(<ChatInput onSend={vi.fn()} externalValue="Hello suggestion" />);
await waitFor(() => {
expect((textarea as HTMLTextAreaElement).value).toBe("Hello suggestion");
});
});
});
describe("ChatInput — send behavior", () => {
it("should call onSend with the message when the send button is clicked", async () => {
const onSend = vi.fn();
render(<ChatInput onSend={onSend} />);
const textarea = screen.getByLabelText(/message input/i);
fireEvent.change(textarea, { target: { value: "Hello world" } });
const sendButton = screen.getByLabelText(/send message/i);
fireEvent.click(sendButton);
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith("Hello world");
});
});
it("should clear the textarea after sending", async () => {
const onSend = vi.fn();
render(<ChatInput onSend={onSend} />);
const textarea = screen.getByLabelText(/message input/i);
fireEvent.change(textarea, { target: { value: "Hello world" } });
fireEvent.click(screen.getByLabelText(/send message/i));
await waitFor(() => {
expect((textarea as HTMLTextAreaElement).value).toBe("");
});
});
it("should show the stop button when streaming", () => {
render(<ChatInput onSend={vi.fn()} isStreaming={true} />);
expect(screen.getByLabelText(/stop generating/i)).toBeDefined();
});
it("should call onStopStreaming when stop button is clicked", () => {
const onStop = vi.fn();
render(<ChatInput onSend={vi.fn()} isStreaming={true} onStopStreaming={onStop} />);
fireEvent.click(screen.getByLabelText(/stop generating/i));
expect(onStop).toHaveBeenCalledOnce();
});
});
describe("ChatInput — command autocomplete", () => {
it("shows no autocomplete for regular text", () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "hello world" } });
});
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
});
it("shows autocomplete dropdown when user types /", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
});
});
it("shows all commands when only / is typed", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
const dropdown = screen.getByTestId("command-autocomplete");
expect(dropdown).toHaveTextContent("/status");
expect(dropdown).toHaveTextContent("/agents");
expect(dropdown).toHaveTextContent("/jobs");
expect(dropdown).toHaveTextContent("/pause");
expect(dropdown).toHaveTextContent("/resume");
expect(dropdown).toHaveTextContent("/help");
});
});
it("filters commands by typed prefix", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/ag" } });
});
await waitFor(() => {
const dropdown = screen.getByTestId("command-autocomplete");
expect(dropdown).toHaveTextContent("/agents");
expect(dropdown).not.toHaveTextContent("/status");
expect(dropdown).not.toHaveTextContent("/pause");
});
});
it("dismisses autocomplete on Escape key", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
});
act(() => {
fireEvent.keyDown(textarea, { key: "Escape" });
});
await waitFor(() => {
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
});
});
it("accepts first command on Tab key", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/stat" } });
});
await waitFor(() => {
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
});
act(() => {
fireEvent.keyDown(textarea, { key: "Tab" });
});
await waitFor(() => {
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
});
expect((textarea as HTMLTextAreaElement).value).toBe("/status ");
});
it("navigates with ArrowDown key", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
});
act(() => {
fireEvent.keyDown(textarea, { key: "ArrowDown" });
});
await waitFor(() => {
const options = screen.getAllByRole("option");
// Second item should be selected after ArrowDown
expect(options[1]).toHaveAttribute("aria-selected", "true");
});
});
it("fills command when clicking a suggestion", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
});
// Click on /agents option
const options = screen.getAllByRole("option");
const agentsOption = options.find((o) => o.textContent.includes("/agents"));
if (!agentsOption) throw new Error("Could not find /agents option");
act(() => {
fireEvent.click(agentsOption);
});
await waitFor(() => {
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
});
expect((textarea as HTMLTextAreaElement).value).toBe("/agents ");
});
it("shows command descriptions", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
const dropdown = screen.getByTestId("command-autocomplete");
expect(dropdown).toHaveTextContent("Show orchestrator health");
expect(dropdown).toHaveTextContent("Pause the job queue");
});
});
it("hides autocomplete when input no longer starts with /", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
});
act(() => {
fireEvent.change(textarea, { target: { value: "" } });
});
await waitFor(() => {
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
});
});
});

View File

@@ -1,19 +1,139 @@
"use client";
import type { KeyboardEvent, RefObject } from "react";
import { useCallback, useState, useEffect } from "react";
import { useCallback, useState, useEffect, useRef } from "react";
import { ORCHESTRATOR_COMMANDS } from "@/hooks/useOrchestratorCommands";
export const AVAILABLE_MODELS = [
{ id: "llama3.2", label: "Llama 3.2" },
{ id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" },
{ id: "gpt-4o", label: "GPT-4o" },
{ id: "deepseek-r1", label: "DeepSeek R1" },
] as const;
export type ModelId = (typeof AVAILABLE_MODELS)[number]["id"];
const STORAGE_KEY_MODEL = "chat:selectedModel";
const STORAGE_KEY_TEMPERATURE = "chat:temperature";
const STORAGE_KEY_MAX_TOKENS = "chat:maxTokens";
export const DEFAULT_TEMPERATURE = 0.7;
export const DEFAULT_MAX_TOKENS = 4096;
export const DEFAULT_MODEL: ModelId = "llama3.2";
function loadStoredModel(): ModelId {
try {
const stored = localStorage.getItem(STORAGE_KEY_MODEL);
if (stored && AVAILABLE_MODELS.some((m) => m.id === stored)) {
return stored as ModelId;
}
} catch {
// localStorage not available
}
return DEFAULT_MODEL;
}
function loadStoredTemperature(): number {
try {
const stored = localStorage.getItem(STORAGE_KEY_TEMPERATURE);
if (stored !== null) {
const parsed = parseFloat(stored);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 2) {
return parsed;
}
}
} catch {
// localStorage not available
}
return DEFAULT_TEMPERATURE;
}
function loadStoredMaxTokens(): number {
try {
const stored = localStorage.getItem(STORAGE_KEY_MAX_TOKENS);
if (stored !== null) {
const parsed = parseInt(stored, 10);
if (!isNaN(parsed) && parsed >= 100 && parsed <= 32000) {
return parsed;
}
}
} catch {
// localStorage not available
}
return DEFAULT_MAX_TOKENS;
}
interface ChatInputProps {
onSend: (message: string) => void;
disabled?: boolean;
inputRef?: RefObject<HTMLTextAreaElement | null>;
isStreaming?: boolean;
onStopStreaming?: () => void;
onModelChange?: (model: ModelId) => void;
onTemperatureChange?: (temperature: number) => void;
onMaxTokensChange?: (maxTokens: number) => void;
onSuggestionFill?: (text: string) => void;
externalValue?: string;
}
export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React.JSX.Element {
export function ChatInput({
onSend,
disabled,
inputRef,
isStreaming = false,
onStopStreaming,
onModelChange,
onTemperatureChange,
onMaxTokensChange,
externalValue,
}: ChatInputProps): React.JSX.Element {
const [message, setMessage] = useState("");
const [version, setVersion] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useState<ModelId>(DEFAULT_MODEL);
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const [isParamsOpen, setIsParamsOpen] = useState(false);
// Command autocomplete state
const [commandSuggestions, setCommandSuggestions] = useState<typeof ORCHESTRATOR_COMMANDS>([]);
const [highlightedCommandIndex, setHighlightedCommandIndex] = useState(0);
const commandDropdownRef = useRef<HTMLDivElement>(null);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const paramsDropdownRef = useRef<HTMLDivElement>(null);
// Stable refs for callbacks so the mount effect stays dependency-free
const onModelChangeRef = useRef(onModelChange);
onModelChangeRef.current = onModelChange;
const onTemperatureChangeRef = useRef(onTemperatureChange);
onTemperatureChangeRef.current = onTemperatureChange;
const onMaxTokensChangeRef = useRef(onMaxTokensChange);
onMaxTokensChangeRef.current = onMaxTokensChange;
// Load persisted values from localStorage on mount only
useEffect(() => {
const storedModel = loadStoredModel();
const storedTemperature = loadStoredTemperature();
const storedMaxTokens = loadStoredMaxTokens();
setSelectedModel(storedModel);
setTemperature(storedTemperature);
setMaxTokens(storedMaxTokens);
// Notify parent of initial values via refs to avoid stale closure
onModelChangeRef.current?.(storedModel);
onTemperatureChangeRef.current?.(storedTemperature);
onMaxTokensChangeRef.current?.(storedMaxTokens);
}, []);
// Sync external value (e.g. from suggestion clicks)
useEffect(() => {
if (externalValue !== undefined) {
setMessage(externalValue);
}
}, [externalValue]);
// Fetch version from static version.json (generated at build time)
useEffect(() => {
interface VersionData {
version?: string;
@@ -24,7 +144,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
.then((res) => res.json() as Promise<VersionData>)
.then((data) => {
if (data.version) {
// Format as "version+commit" for full build identification
const fullVersion = data.commit ? `${data.version}+${data.commit}` : data.version;
setVersion(fullVersion);
}
@@ -34,42 +153,451 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
});
}, []);
// Update command autocomplete suggestions when message changes
useEffect(() => {
const trimmed = message.trimStart();
if (!trimmed.startsWith("/")) {
setCommandSuggestions([]);
setHighlightedCommandIndex(0);
return;
}
// If the input contains a space, a command has been completed — no suggestions
if (trimmed.includes(" ")) {
setCommandSuggestions([]);
setHighlightedCommandIndex(0);
return;
}
const typedCommand = trimmed.toLowerCase();
// Build flat list including aliases
const matches = ORCHESTRATOR_COMMANDS.filter((cmd) => {
if (cmd.name.startsWith(typedCommand)) return true;
if (cmd.aliases?.some((a) => a.startsWith(typedCommand))) return true;
return false;
});
setCommandSuggestions(matches);
setHighlightedCommandIndex(0);
}, [message]);
// Close dropdowns on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target as Node)) {
setIsModelDropdownOpen(false);
}
if (paramsDropdownRef.current && !paramsDropdownRef.current.contains(e.target as Node)) {
setIsParamsOpen(false);
}
if (commandDropdownRef.current && !commandDropdownRef.current.contains(e.target as Node)) {
setCommandSuggestions([]);
}
};
document.addEventListener("mousedown", handleClickOutside);
return (): void => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const handleSubmit = useCallback(() => {
if (message.trim() && !disabled) {
if (message.trim() && !disabled && !isStreaming) {
onSend(message);
setMessage("");
}
}, [message, onSend, disabled]);
}, [message, onSend, disabled, isStreaming]);
const handleStop = useCallback(() => {
onStopStreaming?.();
}, [onStopStreaming]);
const acceptCommand = useCallback((cmdName: string): void => {
setMessage(cmdName + " ");
setCommandSuggestions([]);
setHighlightedCommandIndex(0);
}, []);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
// Enter to send (without Shift)
// Command autocomplete navigation
if (commandSuggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightedCommandIndex((prev) =>
prev < commandSuggestions.length - 1 ? prev + 1 : 0
);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightedCommandIndex((prev) =>
prev > 0 ? prev - 1 : commandSuggestions.length - 1
);
return;
}
if (
e.key === "Tab" ||
(e.key === "Enter" && !e.shiftKey && commandSuggestions.length > 0)
) {
e.preventDefault();
const selected = commandSuggestions[highlightedCommandIndex];
if (selected) {
acceptCommand(selected.name);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
setCommandSuggestions([]);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
// Ctrl/Cmd + Enter to send (alternative)
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit]
[handleSubmit, commandSuggestions, highlightedCommandIndex, acceptCommand]
);
const handleModelSelect = useCallback(
(model: ModelId): void => {
setSelectedModel(model);
try {
localStorage.setItem(STORAGE_KEY_MODEL, model);
} catch {
// ignore
}
onModelChange?.(model);
setIsModelDropdownOpen(false);
},
[onModelChange]
);
const handleTemperatureChange = useCallback(
(value: number): void => {
setTemperature(value);
try {
localStorage.setItem(STORAGE_KEY_TEMPERATURE, value.toString());
} catch {
// ignore
}
onTemperatureChange?.(value);
},
[onTemperatureChange]
);
const handleMaxTokensChange = useCallback(
(value: number): void => {
setMaxTokens(value);
try {
localStorage.setItem(STORAGE_KEY_MAX_TOKENS, value.toString());
} catch {
// ignore
}
onMaxTokensChange?.(value);
},
[onMaxTokensChange]
);
const selectedModelLabel =
AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.label ?? selectedModel;
const characterCount = message.length;
const maxCharacters = 4000;
const isNearLimit = characterCount > maxCharacters * 0.9;
const isOverLimit = characterCount > maxCharacters;
const isInputDisabled = disabled ?? false;
return (
<div className="space-y-3">
<div className="space-y-2">
{/* Model Selector + Params Row */}
<div className="flex items-center gap-2">
{/* Model Selector */}
<div className="relative" ref={modelDropdownRef}>
<button
type="button"
onClick={() => {
setIsModelDropdownOpen((prev) => !prev);
setIsParamsOpen(false);
}}
className="flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
style={{
borderColor: "rgb(var(--border-default))",
backgroundColor: "rgb(var(--surface-1))",
color: "rgb(var(--text-secondary))",
}}
aria-label={`Model: ${selectedModelLabel}. Click to change`}
aria-expanded={isModelDropdownOpen}
aria-haspopup="listbox"
title="Select AI model"
>
<svg
className="h-3 w-3 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83" />
</svg>
<span>{selectedModelLabel}</span>
<svg
className={`h-3 w-3 transition-transform ${isModelDropdownOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{/* Model Dropdown */}
{isModelDropdownOpen && (
<div
className="absolute bottom-full left-0 mb-1 z-50 min-w-[160px] rounded-lg border shadow-lg"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
}}
role="listbox"
aria-label="Available models"
>
{AVAILABLE_MODELS.map((model) => (
<button
key={model.id}
role="option"
aria-selected={model.id === selectedModel}
onClick={() => {
handleModelSelect(model.id);
}}
className="w-full px-3 py-2 text-left text-xs transition-colors first:rounded-t-lg last:rounded-b-lg hover:bg-black/5"
style={{
color:
model.id === selectedModel
? "rgb(var(--accent-primary))"
: "rgb(var(--text-primary))",
fontWeight: model.id === selectedModel ? 600 : 400,
}}
>
{model.label}
{model.id === selectedModel && (
<svg
className="inline-block ml-1.5 h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</div>
)}
</div>
{/* Settings / Params Icon */}
<div className="relative" ref={paramsDropdownRef}>
<button
type="button"
onClick={() => {
setIsParamsOpen((prev) => !prev);
setIsModelDropdownOpen(false);
}}
className="flex items-center justify-center rounded-full border p-1 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
style={{
borderColor: "rgb(var(--border-default))",
backgroundColor: isParamsOpen ? "rgb(var(--surface-2))" : "rgb(var(--surface-1))",
color: "rgb(var(--text-muted))",
}}
aria-label="Chat parameters"
aria-expanded={isParamsOpen}
aria-haspopup="dialog"
title="Configure temperature and max tokens"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</button>
{/* Params Popover */}
{isParamsOpen && (
<div
className="absolute bottom-full left-0 mb-1 z-50 w-64 rounded-lg border p-4 shadow-lg"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
}}
role="dialog"
aria-label="Chat parameters"
>
<h3
className="mb-3 text-xs font-semibold uppercase tracking-wide"
style={{ color: "rgb(var(--text-muted))" }}
>
Parameters
</h3>
{/* Temperature */}
<div className="mb-4">
<div className="mb-1.5 flex items-center justify-between">
<label
className="text-xs font-medium"
style={{ color: "rgb(var(--text-secondary))" }}
htmlFor="temperature-slider"
>
Temperature
</label>
<span
className="text-xs font-mono tabular-nums"
style={{ color: "rgb(var(--accent-primary))" }}
>
{temperature.toFixed(1)}
</span>
</div>
<input
id="temperature-slider"
type="range"
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(e) => {
handleTemperatureChange(parseFloat(e.target.value));
}}
className="w-full h-1.5 rounded-full appearance-none cursor-pointer"
style={{
accentColor: "rgb(var(--accent-primary))",
backgroundColor: "rgb(var(--surface-2))",
}}
aria-label={`Temperature: ${temperature.toFixed(1)}`}
/>
<div
className="mt-1 flex justify-between text-[10px]"
style={{ color: "rgb(var(--text-muted))" }}
>
<span>Precise</span>
<span>Creative</span>
</div>
</div>
{/* Max Tokens */}
<div>
<label
className="mb-1.5 block text-xs font-medium"
style={{ color: "rgb(var(--text-secondary))" }}
htmlFor="max-tokens-input"
>
Max Tokens
</label>
<input
id="max-tokens-input"
type="number"
min={100}
max={32000}
step={100}
value={maxTokens}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val >= 100 && val <= 32000) {
handleMaxTokensChange(val);
}
}}
className="w-full rounded-md border px-2.5 py-1.5 text-xs outline-none focus:ring-2"
style={{
backgroundColor: "rgb(var(--surface-1))",
borderColor: "rgb(var(--border-default))",
color: "rgb(var(--text-primary))",
}}
aria-label="Maximum tokens"
/>
<p className="mt-1 text-[10px]" style={{ color: "rgb(var(--text-muted))" }}>
100 32,000
</p>
</div>
</div>
)}
</div>
</div>
{/* Command Autocomplete Dropdown */}
{commandSuggestions.length > 0 && (
<div
ref={commandDropdownRef}
className="rounded-lg border shadow-lg"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
}}
role="listbox"
aria-label="Command suggestions"
data-testid="command-autocomplete"
>
{commandSuggestions.map((cmd, idx) => (
<button
key={cmd.name}
role="option"
aria-selected={idx === highlightedCommandIndex}
onClick={() => {
acceptCommand(cmd.name);
}}
className="w-full flex items-center gap-3 px-3 py-2 text-left text-sm transition-colors first:rounded-t-lg last:rounded-b-lg"
style={{
backgroundColor:
idx === highlightedCommandIndex
? "rgb(var(--accent-primary) / 0.1)"
: "transparent",
color: "rgb(var(--text-primary))",
}}
>
<span
className="font-mono text-xs font-semibold"
style={{ color: "rgb(var(--accent-primary))" }}
>
{cmd.name}
</span>
{cmd.aliases && cmd.aliases.length > 0 && (
<span className="text-xs" style={{ color: "rgb(var(--text-muted))" }}>
({cmd.aliases.join(", ")})
</span>
)}
<span className="text-xs ml-auto" style={{ color: "rgb(var(--text-secondary))" }}>
{cmd.description}
</span>
</button>
))}
</div>
)}
{/* Input Container */}
<div
className="relative rounded-lg border transition-all duration-150"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: disabled ? "rgb(var(--border-default))" : "rgb(var(--border-strong))",
borderColor:
isInputDisabled || isStreaming
? "rgb(var(--border-default))"
: "rgb(var(--border-strong))",
}}
>
<textarea
@@ -79,8 +607,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
setMessage(e.target.value);
}}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={disabled}
placeholder={isStreaming ? "AI is responding..." : "Type a message..."}
disabled={isInputDisabled || isStreaming}
rows={1}
className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50"
style={{
@@ -97,14 +625,32 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
aria-describedby="input-help"
/>
{/* Send Button */}
{/* Send / Stop Button */}
<div className="absolute bottom-2 right-2 flex items-center gap-2">
{isStreaming ? (
<button
onClick={handleStop}
className="btn-sm rounded-md flex items-center gap-1.5"
style={{
backgroundColor: "rgb(var(--semantic-error))",
color: "white",
padding: "0.25rem 0.75rem",
}}
aria-label="Stop generating"
title="Stop generating"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
<span className="hidden sm:inline text-sm font-medium">Stop</span>
</button>
) : (
<button
onClick={handleSubmit}
disabled={(disabled ?? !message.trim()) || isOverLimit}
disabled={isInputDisabled || !message.trim() || isOverLimit}
className="btn-primary btn-sm rounded-md"
style={{
opacity: disabled || !message.trim() || isOverLimit ? 0.5 : 1,
opacity: isInputDisabled || !message.trim() || isOverLimit ? 0.5 : 1,
}}
aria-label="Send message"
>
@@ -119,6 +665,7 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
</svg>
<span className="hidden sm:inline">Send</span>
</button>
)}
</div>
</div>
@@ -128,7 +675,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
style={{ color: "rgb(var(--text-muted))" }}
id="input-help"
>
{/* Keyboard Shortcuts */}
<div className="hidden items-center gap-4 sm:flex">
<div className="flex items-center gap-1.5">
<span className="kbd">Enter</span>
@@ -142,10 +688,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
</div>
</div>
{/* Mobile hint */}
<div className="sm:hidden">Tap send or press Enter</div>
{/* Character Count */}
<div
className="flex items-center gap-2"
style={{

View File

@@ -250,6 +250,46 @@ describe("ChatOverlay", () => {
});
});
describe("new conversation button", () => {
it("should render the new conversation button when chat is open", async () => {
const { useChatOverlay } = await import("../../hooks/useChatOverlay");
vi.mocked(useChatOverlay).mockReturnValue({
isOpen: true,
isMinimized: false,
open: mockOpen,
close: mockClose,
minimize: mockMinimize,
expand: mockExpand,
toggle: mockToggle,
toggleMinimize: mockToggleMinimize,
});
render(<ChatOverlay />);
const newConvBtn = screen.getByRole("button", { name: /new conversation/i });
expect(newConvBtn).toBeDefined();
});
it("should have a tooltip on the new conversation button", async () => {
const { useChatOverlay } = await import("../../hooks/useChatOverlay");
vi.mocked(useChatOverlay).mockReturnValue({
isOpen: true,
isMinimized: false,
open: mockOpen,
close: mockClose,
minimize: mockMinimize,
expand: mockExpand,
toggle: mockToggle,
toggleMinimize: mockToggleMinimize,
});
render(<ChatOverlay />);
const newConvBtn = screen.getByRole("button", { name: /new conversation/i });
expect(newConvBtn.getAttribute("title")).toContain("Cmd+N");
});
});
describe("responsive design", () => {
it("should render as a sidebar on desktop", () => {
render(<ChatOverlay />);

View File

@@ -164,6 +164,27 @@ export function ChatOverlay(): React.JSX.Element {
{/* Header Controls */}
<div className="flex items-center gap-1">
{/* New Conversation Button */}
<button
onClick={() => {
chatRef.current?.startNewConversation(null);
}}
className="rounded p-1.5 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
aria-label="New conversation"
title="New conversation (Cmd+N)"
>
<svg
className="h-4 w-4"
style={{ color: "rgb(var(--text-secondary))" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path d="M12 4v16m8-8H4" />
</svg>
</button>
{/* Minimize Button */}
<button
onClick={minimize}

View File

@@ -1,11 +1,13 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Message } from "@/hooks/useChat";
interface MessageListProps {
messages: Message[];
isLoading: boolean;
isStreaming?: boolean;
streamingMessageId?: string;
loadingQuip?: string | null;
}
@@ -14,7 +16,6 @@ interface MessageListProps {
* Extracts <thinking>...</thinking> or <think>...</think> blocks.
*/
function parseThinking(content: string): { thinking: string | null; response: string } {
// Match <thinking>...</thinking> or <think>...</think> blocks
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
const matches = content.match(thinkingRegex);
@@ -22,14 +23,12 @@ function parseThinking(content: string): { thinking: string | null; response: st
return { thinking: null, response: content };
}
// Extract thinking content
let thinking = "";
for (const match of matches) {
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
thinking += innerContent.trim() + "\n";
}
// Remove thinking blocks from response
const response = content.replace(thinkingRegex, "").trim();
const trimmedThinking = thinking.trim();
@@ -42,25 +41,47 @@ function parseThinking(content: string): { thinking: string | null; response: st
export function MessageList({
messages,
isLoading,
isStreaming = false,
streamingMessageId,
loadingQuip,
}: MessageListProps): React.JSX.Element {
const bottomRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when messages change or streaming tokens arrive
useEffect(() => {
if (isStreaming || isLoading) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages, isStreaming, isLoading]);
return (
<div className="space-y-6" role="log" aria-label="Chat messages">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
<MessageBubble
key={message.id}
message={message}
isStreaming={isStreaming && message.id === streamingMessageId}
/>
))}
{isLoading && <LoadingIndicator {...(loadingQuip != null && { quip: loadingQuip })} />}
{isLoading && !isStreaming && (
<LoadingIndicator {...(loadingQuip != null && { quip: loadingQuip })} />
)}
<div ref={bottomRef} />
</div>
);
}
function MessageBubble({ message }: { message: Message }): React.JSX.Element {
interface MessageBubbleProps {
message: Message;
isStreaming?: boolean;
}
function MessageBubble({ message, isStreaming = false }: MessageBubbleProps): React.JSX.Element {
const isUser = message.role === "user";
const [copied, setCopied] = useState(false);
const [thinkingExpanded, setThinkingExpanded] = useState(false);
// Parse thinking from content (or use pre-parsed thinking field)
const { thinking, response } = message.thinking
? { thinking: message.thinking, response: message.content }
: parseThinking(message.content);
@@ -73,7 +94,6 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
setCopied(false);
}, 2000);
} catch (err) {
// Silently fail - clipboard copy is non-critical
void err;
}
}, [response]);
@@ -106,8 +126,21 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
{isUser ? "You" : "AI Assistant"}
</span>
{/* Streaming indicator in header */}
{!isUser && isStreaming && (
<span
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{
backgroundColor: "rgb(var(--accent-primary) / 0.15)",
color: "rgb(var(--accent-primary))",
}}
aria-label="Streaming"
>
streaming
</span>
)}
{/* Model indicator for assistant messages */}
{!isUser && message.model && (
{!isUser && message.model && !isStreaming && (
<span
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{
@@ -200,9 +233,19 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
border: isUser ? "none" : "1px solid rgb(var(--border-default))",
}}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{response}</p>
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{response}
{/* Blinking cursor during streaming */}
{isStreaming && !isUser && (
<span
className="streaming-cursor inline-block ml-0.5 align-middle"
aria-hidden="true"
/>
)}
</p>
{/* Copy Button - appears on hover */}
{/* 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"
@@ -237,6 +280,7 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
</svg>
)}
</button>
)}
</div>
</div>
</div>

View File

@@ -11,9 +11,17 @@
*/
export { Chat, type ChatRef, type NewConversationData } from "./Chat";
export { ChatInput } from "./ChatInput";
export {
ChatInput,
AVAILABLE_MODELS,
DEFAULT_MODEL,
DEFAULT_TEMPERATURE,
DEFAULT_MAX_TOKENS,
} from "./ChatInput";
export type { ModelId } from "./ChatInput";
export { MessageList } from "./MessageList";
export { ConversationSidebar, type ConversationSidebarRef } from "./ConversationSidebar";
export { BackendStatusBanner } from "./BackendStatusBanner";
export { ChatOverlay } from "./ChatOverlay";
export { ChatEmptyState } from "./ChatEmptyState";
export type { Message } from "@/hooks/useChat";

View File

@@ -1,5 +1,6 @@
import type { ReactElement } from "react";
import { Card, SectionHeader, Badge } from "@mosaic/ui";
import type { RecentActivity } from "@/lib/api/dashboard";
type BadgeVariantType =
| "badge-amber"
@@ -10,7 +11,7 @@ type BadgeVariantType =
| "badge-purple"
| "badge-pulse";
interface ActivityItem {
interface ActivityDisplayItem {
id: string;
icon: string;
iconBg: string;
@@ -18,82 +19,91 @@ interface ActivityItem {
highlight: string;
rest: string;
timestamp: string;
badge?: {
badge?:
| {
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[] = [
{
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",
},
];
/* ------------------------------------------------------------------ */
/* Components */
/* ------------------------------------------------------------------ */
interface ActivityItemRowProps {
item: ActivityItem;
item: ActivityDisplayItem;
}
function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
@@ -102,8 +112,8 @@ function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "8px 0",
gap: 12,
padding: "10px 0",
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 (
<Card>
<SectionHeader title="Activity Feed" subtitle="Recent agent events" />
<div>
{activityItems.map((item) => (
<ActivityItemRow key={item.id} item={item} />
))}
{displayItems.length > 0 ? (
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>
</Card>
);

View File

@@ -1,45 +1,69 @@
import type { ReactElement } from "react";
import { MetricsStrip, type MetricCell } from "@mosaic/ui";
import type { DashboardMetrics as DashboardMetricsData } from "@/lib/api/dashboard";
const cells: MetricCell[] = [
export interface DashboardMetricsProps {
metrics?: DashboardMetricsData | undefined;
}
function formatNumber(n: number): string {
return n.toLocaleString();
}
function buildCells(metrics: DashboardMetricsData): MetricCell[] {
return [
{
label: "Active Agents",
value: "47",
value: formatNumber(metrics.activeAgents),
color: "var(--ms-blue-400)",
trend: { direction: "up", text: "↑ +3 from yesterday" },
trend: { direction: "neutral", text: "currently active" },
},
{
label: "Tasks Completed",
value: "1,284",
value: formatNumber(metrics.tasksCompleted),
color: "var(--ms-teal-400)",
trend: { direction: "up", text: "↑ +128 today" },
trend: { direction: "neutral", text: `of ${formatNumber(metrics.totalTasks)} total` },
},
{
label: "Avg Response Time",
value: "2.4s",
label: "Total Tasks",
value: formatNumber(metrics.totalTasks),
color: "var(--ms-purple-400)",
trend: { direction: "down", text: "↓ -0.3s improved" },
trend: { direction: "neutral", text: "across workspace" },
},
{
label: "Token Usage",
value: "3.2M",
label: "In Progress",
value: formatNumber(metrics.tasksInProgress),
color: "var(--ms-amber-400)",
trend: { direction: "neutral", text: "78% of budget" },
trend: { direction: "neutral", text: "tasks running" },
},
{
label: "Error Rate",
value: "0.4%",
value: `${String(metrics.errorRate)}%`,
color: "var(--ms-red-400)",
trend: { direction: "down", text: "↓ -0.1% improved" },
trend: {
direction: metrics.errorRate > 1 ? "up" : "down",
text: metrics.errorRate > 1 ? "above threshold" : "within threshold",
},
},
{
label: "Active Projects",
value: "8",
value: formatNumber(metrics.activeProjects),
color: "var(--ms-cyan-500)",
trend: { direction: "neutral", text: "2 deploying" },
trend: { direction: "neutral", text: "in workspace" },
},
];
}
export function DashboardMetrics(): ReactElement {
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({ metrics }: DashboardMetricsProps): ReactElement {
const cells = metrics ? buildCells(metrics) : EMPTY_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,22 +3,15 @@
import { useState } from "react";
import type { ReactElement } from "react";
import { Card, SectionHeader, Badge, Dot } from "@mosaic/ui";
import type { ActiveJob } from "@/lib/api/dashboard";
interface AgentNode {
id: string;
initials: string;
avatarColor: string;
name: string;
task: string;
status: "teal" | "blue" | "amber" | "red" | "muted";
}
/* ------------------------------------------------------------------ */
/* Internal display types */
/* ------------------------------------------------------------------ */
interface OrchestratorSession {
id: string;
orchId: string;
name: string;
badge: string;
badgeVariant:
type DotVariant = "teal" | "blue" | "amber" | "red" | "muted";
type BadgeVariant =
| "badge-teal"
| "badge-amber"
| "badge-red"
@@ -26,65 +19,113 @@ interface OrchestratorSession {
| "badge-muted"
| "badge-purple"
| "badge-pulse";
interface AgentNode {
id: string;
initials: string;
avatarColor: string;
name: string;
task: string;
status: DotVariant;
statusLabel: string;
}
interface OrchestratorSession {
id: string;
orchId: string;
name: string;
badge: string;
badgeVariant: BadgeVariant;
duration: string;
progress: number;
agents: AgentNode[];
}
const sessions: OrchestratorSession[] = [
{
id: "s1",
orchId: "ORCH-001",
name: "infra-refactor",
badge: "running",
badgeVariant: "badge-teal",
duration: "2h 14m",
agents: [
{
id: "a1",
initials: "PL",
avatarColor: "rgba(47,128,255,0.15)",
name: "planner-agent",
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",
},
],
},
export interface OrchestratorSessionsProps {
jobs?: ActiveJob[] | undefined;
}
/* ------------------------------------------------------------------ */
/* Mapping helpers */
/* ------------------------------------------------------------------ */
const STEP_COLORS: string[] = [
"rgba(47,128,255,0.15)",
"rgba(20,184,166,0.15)",
"rgba(245,158,11,0.15)",
"rgba(139,92,246,0.15)",
"rgba(229,72,77,0.15)",
];
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 {
agent: AgentNode;
}
@@ -155,6 +196,16 @@ function AgentNodeItem({ agent }: AgentNodeItemProps): ReactElement {
</div>
</div>
<Dot variant={agent.status} />
<span
style={{
fontSize: "0.65rem",
fontFamily: "var(--mono)",
color: "var(--muted)",
textTransform: "uppercase",
}}
>
{agent.statusLabel}
</span>
</div>
);
}
@@ -169,7 +220,7 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
style={{
background: "var(--bg-mid)",
border: "1px solid var(--border)",
borderRadius: "var(--r-md)",
borderRadius: "var(--r)",
padding: "12px 14px",
marginBottom: 10,
}}
@@ -182,7 +233,7 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
marginBottom: 10,
}}
>
<Dot variant="teal" />
<Dot variant={statusToDotVariant(session.badge)} />
<span
style={{
fontFamily: "var(--mono)",
@@ -214,6 +265,27 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
{session.duration}
</span>
</div>
{session.progress > 0 && (
<div
style={{
height: 4,
borderRadius: 2,
background: "var(--border)",
marginBottom: 10,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${String(session.progress)}%`,
background: "var(--primary)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{session.agents.map((agent) => (
<AgentNodeItem key={agent.id} agent={agent} />
@@ -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 (
<Card>
<SectionHeader
title="Active Orchestrator Sessions"
subtitle="3 of 8 projects running"
actions={<Badge variant="badge-teal">3 active</Badge>}
subtitle={
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>
{sessions.map((session) => (
<OrchCard key={session.id} session={session} />
))}
{sessions.length > 0 ? (
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>
</Card>
);

View File

@@ -12,10 +12,10 @@ interface QuickAction {
}
const actions: QuickAction[] = [
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.15)" },
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.15)" },
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.15)" },
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,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.12)" },
{ 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.12)" },
];
interface ActionButtonProps {
@@ -36,24 +36,25 @@ function ActionButton({ action }: ActionButtonProps): ReactElement {
}}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: "16px 12px",
borderRadius: "var(--r-md)",
padding: "10px 12px",
borderRadius: "var(--r)",
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
background: hovered ? "var(--surface)" : "var(--bg-mid)",
cursor: "pointer",
transition: "border-color 0.15s, background 0.15s",
transition: "border-color 0.15s, background 0.15s, color 0.15s",
width: "100%",
fontSize: "0.8rem",
fontWeight: 600,
color: hovered ? "var(--text)" : "var(--text-2)",
}}
>
<div
style={{
width: 24,
height: 24,
borderRadius: 6,
borderRadius: 5,
background: action.iconBg,
display: "flex",
alignItems: "center",
@@ -63,15 +64,7 @@ function ActionButton({ action }: ActionButtonProps): ReactElement {
>
{action.icon}
</div>
<span
style={{
fontSize: "0.8rem",
fontWeight: 600,
color: "var(--text)",
}}
>
{action.label}
</span>
<span>{action.label}</span>
</button>
);
}
@@ -84,7 +77,7 @@ export function QuickActions(): ReactElement {
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 10,
gap: 8,
}}
>
{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 { 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;
label: string;
usage: string;
@@ -9,39 +26,28 @@ interface ModelBudget {
variant: ProgressBarVariant;
}
const models: ModelBudget[] = [
{
id: "sonnet",
label: "claude-3-5-sonnet",
usage: "2.1M / 3M",
value: 70,
variant: "blue",
},
{
id: "haiku",
label: "claude-3-haiku",
usage: "890K / 5M",
value: 18,
variant: "teal",
},
{
id: "gpt4o",
label: "gpt-4o",
usage: "320K / 1M",
value: 32,
variant: "purple",
},
{
id: "llama",
label: "local/llama-3.3",
usage: "unlimited",
value: 55,
variant: "amber",
},
];
function mapBudgetToDisplay(entry: TokenBudgetEntry, index: number): ModelBudgetDisplay {
const percent = entry.limit > 0 ? Math.round((entry.used / entry.limit) * 100) : 0;
const usage =
entry.limit > 0
? `${formatTokenCount(entry.used)} / ${formatTokenCount(entry.limit)}`
: "unlimited";
return {
id: entry.model,
label: entry.model,
usage,
value: percent,
variant: VARIANT_CYCLE[index % VARIANT_CYCLE.length] ?? "blue",
};
}
/* ------------------------------------------------------------------ */
/* Components */
/* ------------------------------------------------------------------ */
interface ModelRowProps {
model: ModelBudget;
model: ModelBudgetDisplay;
}
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 (
<Card>
<SectionHeader title="Token Budget" subtitle="Usage by model" />
<div>
{models.map((model) => (
<ModelRow key={model.id} model={model} />
))}
{displayModels.length > 0 ? (
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>
</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";
import React, { useState, useRef } from "react";
import { LinkAutocomplete } from "./LinkAutocomplete";
import React from "react";
import { KnowledgeEditor } from "./KnowledgeEditor";
interface EntryEditorProps {
content: string;
@@ -9,57 +9,21 @@ interface EntryEditorProps {
}
/**
* EntryEditor - Markdown editor with live preview and link autocomplete
* EntryEditor - WYSIWYG editor for knowledge entries.
* Wraps KnowledgeEditor (Tiptap) with markdown round-trip.
* Content is stored as markdown; the editor provides rich text editing.
*/
export function EntryEditor({ content, onChange }: EntryEditorProps): React.JSX.Element {
const [showPreview, setShowPreview] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
return (
<div className="entry-editor relative">
<div className="flex justify-between items-center mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Content (Markdown)
<div className="entry-editor">
<label className="block text-sm font-medium mb-2" style={{ color: "var(--text-2)" }}>
Content
</label>
<button
type="button"
onClick={() => {
setShowPreview(!showPreview);
}}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
{showPreview ? "Edit" : "Preview"}
</button>
</div>
{showPreview ? (
<div className="prose prose-sm max-w-none dark:prose-invert p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 min-h-[300px]">
<div className="whitespace-pre-wrap">{content}</div>
</div>
) : (
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => {
onChange(e.target.value);
}}
className="w-full min-h-[300px] p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Write your content here... (Markdown supported)"
<KnowledgeEditor
content={content}
onChange={onChange}
placeholder="Write your content here... Supports markdown formatting."
/>
<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>
);
}

View File

@@ -1,6 +1,7 @@
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
import { EntryCard } from "./EntryCard";
import { BookOpen } from "lucide-react";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
interface EntryListProps {
entries: KnowledgeEntryWithTags[];
@@ -20,18 +21,22 @@ export function EntryList({
if (isLoading) {
return (
<div className="flex justify-center items-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">Loading entries...</span>
<MosaicSpinner size={36} label="Loading entries..." />
</div>
);
}
if (entries.length === 0) {
return (
<div className="text-center p-12 bg-white rounded-lg shadow-sm border border-gray-200">
<BookOpen className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-lg text-gray-700 font-medium">No entries found</p>
<p className="text-sm text-gray-500 mt-2">
<div
className="text-center p-12 rounded-lg border"
style={{ background: "var(--surface)", borderColor: "var(--border)" }}
>
<BookOpen className="w-12 h-12 mx-auto mb-3" style={{ color: "var(--text-muted)" }} />
<p className="text-lg font-medium" style={{ color: "var(--text-muted)" }}>
No entries found
</p>
<p className="text-sm mt-2" style={{ color: "var(--text-muted)" }}>
Try adjusting your filters or create a new entry
</p>
</div>

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
vi.mock("@/lib/api/knowledge");
// Mock MosaicSpinner to expose a test ID
vi.mock("@/components/ui/MosaicSpinner", () => ({
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
<div data-testid="loading-spinner">{label ?? "Loading..."}</div>
),
}));
// Mock elkjs since it requires APIs not available in test environment
vi.mock("elkjs/lib/elk.bundled.js", () => ({
default: class ELK {
layout(graph: {
children?: { id: string }[];
}): Promise<{ children: { id: string; x: number; y: number }[] }> {
return Promise.resolve({
children: (graph.children ?? []).map((child: { id: string }, i: number) => ({
id: child.id,
x: i * 100,
y: i * 100,
})),
});
}
},
}));
// Mock Next.js router
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({

View File

@@ -16,6 +16,7 @@ import {
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { fetchKnowledgeGraph } from "@/lib/api/knowledge";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
import ELK from "elkjs/lib/elk.bundled.js";
// PDA-friendly status colors from CLAUDE.md
@@ -376,10 +377,7 @@ export function KnowledgeGraphViewer({
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<div
data-testid="loading-spinner"
className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"
/>
<MosaicSpinner size={48} label="Loading knowledge graph..." />
</div>
);
}
@@ -387,11 +385,14 @@ export function KnowledgeGraphViewer({
if (error || !graphData) {
return (
<div className="flex flex-col items-center justify-center h-screen p-8">
<div className="text-red-500 text-xl font-semibold mb-2">Error Loading Graph</div>
<div className="text-xl font-semibold mb-2" style={{ color: "var(--danger)" }}>
Error Loading Graph
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{error}</div>
<button
onClick={loadGraph}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
className="mt-4 px-4 py-2 rounded text-white"
style={{ background: "var(--danger)" }}
>
Retry
</button>

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { fetchKnowledgeStats } from "@/lib/api/knowledge";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
import Link from "next/link";
interface KnowledgeStats {
@@ -61,13 +62,20 @@ export function StatsDashboard(): React.JSX.Element {
if (isLoading) {
return (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
<MosaicSpinner size={36} label="Loading statistics..." />
</div>
);
}
if (error || !stats) {
return <div className="p-8 text-center text-red-500">Error loading statistics: {error}</div>;
return (
<div className="p-8 text-center">
<p className="font-medium mb-2" style={{ color: "var(--danger)" }}>
Error loading statistics
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{error}</p>
</div>
);
}
const { overview, mostConnected, recentActivity, tagDistribution } = stats;

View File

@@ -1,13 +1,29 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { EntryEditor } from "../EntryEditor";
// Mock the LinkAutocomplete component
vi.mock("../LinkAutocomplete", () => ({
LinkAutocomplete: (): React.JSX.Element => (
<div data-testid="link-autocomplete">LinkAutocomplete</div>
// Mock KnowledgeEditor since Tiptap requires a full DOM
vi.mock("../KnowledgeEditor", () => ({
KnowledgeEditor: ({
content,
onChange,
placeholder,
}: {
content: string;
onChange: (md: string) => void;
placeholder?: string;
}): React.JSX.Element => (
<div data-testid="knowledge-editor" data-content={content} data-placeholder={placeholder}>
<button
data-testid="trigger-change"
onClick={(): void => {
onChange("updated content");
}}
>
Change
</button>
</div>
),
}));
@@ -21,133 +37,50 @@ describe("EntryEditor", (): void => {
vi.clearAllMocks();
});
it("should render textarea in edit mode by default", (): void => {
it("should render KnowledgeEditor component", (): void => {
render(<EntryEditor {...defaultProps} />);
const textarea = screen.getByPlaceholderText(/Write your content here/);
expect(textarea).toBeInTheDocument();
expect(textarea.tagName).toBe("TEXTAREA");
expect(screen.getByTestId("knowledge-editor")).toBeInTheDocument();
});
it("should display current content in textarea", (): void => {
it("should have a content label", (): void => {
render(<EntryEditor {...defaultProps} />);
expect(screen.getByText("Content")).toBeInTheDocument();
});
it("should pass content to KnowledgeEditor", (): void => {
const content = "# Test Content\n\nThis is a test.";
render(<EntryEditor {...defaultProps} content={content} />);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const textarea = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
expect(textarea.value).toBe(content);
const editor = screen.getByTestId("knowledge-editor");
expect(editor).toHaveAttribute("data-content", content);
});
it("should call onChange when content is modified", async (): Promise<void> => {
it("should pass placeholder to KnowledgeEditor", (): void => {
render(<EntryEditor {...defaultProps} />);
const editor = screen.getByTestId("knowledge-editor");
expect(editor).toHaveAttribute(
"data-placeholder",
"Write your content here... Supports markdown formatting."
);
});
it("should forward onChange to KnowledgeEditor", async (): Promise<void> => {
const { default: userEvent } = await import("@testing-library/user-event");
const user = userEvent.setup();
const onChangeMock = vi.fn();
render(<EntryEditor {...defaultProps} onChange={onChangeMock} />);
const textarea = screen.getByPlaceholderText(/Write your content here/);
await user.type(textarea, "Hello");
expect(onChangeMock).toHaveBeenCalled();
await user.click(screen.getByTestId("trigger-change"));
expect(onChangeMock).toHaveBeenCalledWith("updated content");
});
it("should toggle between edit and preview modes", async (): Promise<void> => {
const user = userEvent.setup();
const content = "# Test\n\nPreview this content.";
it("should render with entry-editor wrapper class", (): void => {
const { container } = render(<EntryEditor {...defaultProps} />);
render(<EntryEditor {...defaultProps} content={content} />);
// Initially in edit mode
expect(screen.getByPlaceholderText(/Write your content here/)).toBeInTheDocument();
expect(screen.getByText("Preview")).toBeInTheDocument();
// Switch to preview mode
const previewButton = screen.getByText("Preview");
await user.click(previewButton);
// Should show preview
expect(screen.queryByPlaceholderText(/Write your content here/)).not.toBeInTheDocument();
expect(screen.getByText("Edit")).toBeInTheDocument();
// Check for partial content (newlines may split text across elements)
expect(screen.getByText(/Test/)).toBeInTheDocument();
expect(screen.getByText(/Preview this content/)).toBeInTheDocument();
// Switch back to edit mode
const editButton = screen.getByText("Edit");
await user.click(editButton);
// Should show textarea again
expect(screen.getByPlaceholderText(/Write your content here/)).toBeInTheDocument();
expect(screen.getByText("Preview")).toBeInTheDocument();
});
it("should render LinkAutocomplete component in edit mode", (): void => {
render(<EntryEditor {...defaultProps} />);
expect(screen.getByTestId("link-autocomplete")).toBeInTheDocument();
});
it("should not render LinkAutocomplete in preview mode", async (): Promise<void> => {
const user = userEvent.setup();
render(<EntryEditor {...defaultProps} />);
// LinkAutocomplete should be present in edit mode
expect(screen.getByTestId("link-autocomplete")).toBeInTheDocument();
// Switch to preview mode
const previewButton = screen.getByText("Preview");
await user.click(previewButton);
// LinkAutocomplete should not be in preview mode
expect(screen.queryByTestId("link-autocomplete")).not.toBeInTheDocument();
});
it("should show help text about wiki-link syntax", (): void => {
render(<EntryEditor {...defaultProps} />);
expect(screen.getByText(/Type/)).toBeInTheDocument();
expect(screen.getByText(/\[\[/)).toBeInTheDocument();
expect(screen.getByText(/to insert links/)).toBeInTheDocument();
});
it("should maintain content when toggling between modes", async (): Promise<void> => {
const user = userEvent.setup();
const content = "# My Content\n\nThis should persist.";
render(<EntryEditor {...defaultProps} content={content} />);
// Verify content in edit mode
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const textarea = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
expect(textarea.value).toBe(content);
// Toggle to preview
await user.click(screen.getByText("Preview"));
// Check for partial content (newlines may split text across elements)
expect(screen.getByText(/My Content/)).toBeInTheDocument();
expect(screen.getByText(/This should persist/)).toBeInTheDocument();
// Toggle back to edit
await user.click(screen.getByText("Edit"));
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const textareaAfter = screen.getByPlaceholderText(
/Write your content here/
) as HTMLTextAreaElement;
expect(textareaAfter.value).toBe(content);
});
it("should apply correct styling classes", (): void => {
render(<EntryEditor {...defaultProps} />);
const textarea = screen.getByPlaceholderText(/Write your content here/);
expect(textarea).toHaveClass("font-mono");
expect(textarea).toHaveClass("text-sm");
expect(textarea).toHaveClass("min-h-[300px]");
});
it("should have label for content field", (): void => {
render(<EntryEditor {...defaultProps} />);
expect(screen.getByText("Content (Markdown)")).toBeInTheDocument();
expect(container.querySelector(".entry-editor")).toBeInTheDocument();
});
});

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",
gap: "11px",
padding: "9px 10px",
borderRadius: "6px",
borderRadius: "var(--r-sm)",
fontSize: "0.875rem",
fontWeight: 500,
color: isActive ? "var(--text)" : "var(--muted)",

View File

@@ -0,0 +1,368 @@
/**
* @file AgentTerminal.test.tsx
* @description Unit tests for the AgentTerminal component
*
* Tests cover:
* - Output rendering
* - Status display (status indicator + badge)
* - ANSI stripping
* - Agent header information (type, duration, jobId)
* - Auto-scroll behavior
* - Copy-to-clipboard
* - Error message display
* - Empty state rendering
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import type { ReactElement } from "react";
import { AgentTerminal } from "./AgentTerminal";
import type { AgentSession } from "@/hooks/useAgentStream";
// ==========================================
// Mock navigator.clipboard
// ==========================================
const mockWriteText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
value: { writeText: mockWriteText },
writable: true,
configurable: true,
});
// ==========================================
// Factory helpers
// ==========================================
function makeAgent(overrides: Partial<AgentSession> = {}): AgentSession {
return {
agentId: "test-agent-1",
agentType: "worker",
status: "running",
outputLines: [],
startedAt: Date.now() - 5000, // 5s ago
...overrides,
};
}
// ==========================================
// Tests
// ==========================================
describe("AgentTerminal", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: false });
});
afterEach(() => {
vi.useRealTimers();
});
// ==========================================
// Rendering
// ==========================================
describe("rendering", () => {
it("renders the agent terminal container", () => {
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
expect(screen.getByTestId("agent-terminal")).toBeInTheDocument();
});
it("has region role with agent type label", () => {
render((<AgentTerminal agent={makeAgent({ agentType: "planner" })} />) as ReactElement);
expect(screen.getByRole("region", { name: "Agent output: planner" })).toBeInTheDocument();
});
it("sets data-agent-id attribute", () => {
render((<AgentTerminal agent={makeAgent({ agentId: "my-agent-123" })} />) as ReactElement);
const container = screen.getByTestId("agent-terminal");
expect(container).toHaveAttribute("data-agent-id", "my-agent-123");
});
it("applies className prop to the outer container", () => {
render((<AgentTerminal agent={makeAgent()} className="custom-cls" />) as ReactElement);
expect(screen.getByTestId("agent-terminal")).toHaveClass("custom-cls");
});
});
// ==========================================
// Header
// ==========================================
describe("header", () => {
it("renders the agent type label", () => {
render((<AgentTerminal agent={makeAgent({ agentType: "coordinator" })} />) as ReactElement);
expect(screen.getByTestId("agent-type-label")).toHaveTextContent("coordinator");
});
it("includes jobId in the label when provided", () => {
render(
(
<AgentTerminal agent={makeAgent({ agentType: "worker", jobId: "job-42" })} />
) as ReactElement
);
expect(screen.getByTestId("agent-type-label")).toHaveTextContent("worker · job-42");
});
it("does not show jobId separator when jobId is absent", () => {
render((<AgentTerminal agent={makeAgent({ agentType: "worker" })} />) as ReactElement);
expect(screen.getByTestId("agent-type-label")).not.toHaveTextContent("·");
});
it("renders the duration element", () => {
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
expect(screen.getByTestId("agent-duration")).toBeInTheDocument();
});
it("shows seconds for short-running agents", () => {
const agent = makeAgent({ startedAt: Date.now() - 8000 });
render((<AgentTerminal agent={agent} />) as ReactElement);
expect(screen.getByTestId("agent-duration")).toHaveTextContent("s");
});
it("shows minutes for long-running agents", () => {
const agent = makeAgent({ startedAt: Date.now() - 125000 }); // 2m 5s
render((<AgentTerminal agent={agent} />) as ReactElement);
expect(screen.getByTestId("agent-duration")).toHaveTextContent("m");
});
});
// ==========================================
// Status indicator
// ==========================================
describe("status indicator", () => {
it("shows a running indicator for running status", () => {
render((<AgentTerminal agent={makeAgent({ status: "running" })} />) as ReactElement);
const indicator = screen.getByTestId("status-indicator");
expect(indicator).toHaveAttribute("data-status", "running");
});
it("shows a spawning indicator for spawning status", () => {
render((<AgentTerminal agent={makeAgent({ status: "spawning" })} />) as ReactElement);
const indicator = screen.getByTestId("status-indicator");
expect(indicator).toHaveAttribute("data-status", "spawning");
});
it("shows completed indicator for completed status", () => {
render(
(
<AgentTerminal agent={makeAgent({ status: "completed", endedAt: Date.now() })} />
) as ReactElement
);
const indicator = screen.getByTestId("status-indicator");
expect(indicator).toHaveAttribute("data-status", "completed");
});
it("shows error indicator for error status", () => {
render(
(
<AgentTerminal agent={makeAgent({ status: "error", endedAt: Date.now() })} />
) as ReactElement
);
const indicator = screen.getByTestId("status-indicator");
expect(indicator).toHaveAttribute("data-status", "error");
});
});
// ==========================================
// Status badge
// ==========================================
describe("status badge", () => {
it("renders the status badge", () => {
render((<AgentTerminal agent={makeAgent({ status: "running" })} />) as ReactElement);
expect(screen.getByTestId("status-badge")).toHaveTextContent("running");
});
it("shows 'spawning' badge for spawning status", () => {
render((<AgentTerminal agent={makeAgent({ status: "spawning" })} />) as ReactElement);
expect(screen.getByTestId("status-badge")).toHaveTextContent("spawning");
});
it("shows 'completed' badge for completed status", () => {
render(
(
<AgentTerminal agent={makeAgent({ status: "completed", endedAt: Date.now() })} />
) as ReactElement
);
expect(screen.getByTestId("status-badge")).toHaveTextContent("completed");
});
it("shows 'error' badge for error status", () => {
render(
(
<AgentTerminal agent={makeAgent({ status: "error", endedAt: Date.now() })} />
) as ReactElement
);
expect(screen.getByTestId("status-badge")).toHaveTextContent("error");
});
});
// ==========================================
// Output rendering
// ==========================================
describe("output area", () => {
it("renders the output pre element", () => {
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
expect(screen.getByTestId("agent-output")).toBeInTheDocument();
});
it("shows 'Waiting for output...' when outputLines is empty and status is running", () => {
render(
(
<AgentTerminal agent={makeAgent({ status: "running", outputLines: [] })} />
) as ReactElement
);
expect(screen.getByTestId("agent-output")).toHaveTextContent("Waiting for output...");
});
it("shows 'Spawning agent...' when status is spawning and no output", () => {
render(
(
<AgentTerminal agent={makeAgent({ status: "spawning", outputLines: [] })} />
) as ReactElement
);
expect(screen.getByTestId("agent-output")).toHaveTextContent("Spawning agent...");
});
it("renders output lines as text content", () => {
const agent = makeAgent({
outputLines: ["Hello world\n", "Second line\n"],
});
render((<AgentTerminal agent={agent} />) as ReactElement);
const output = screen.getByTestId("agent-output");
expect(output).toHaveTextContent("Hello world");
expect(output).toHaveTextContent("Second line");
});
it("strips ANSI escape codes from output", () => {
const agent = makeAgent({
outputLines: ["\x1b[32mGreen text\x1b[0m\n"],
});
render((<AgentTerminal agent={agent} />) as ReactElement);
const output = screen.getByTestId("agent-output");
expect(output).toHaveTextContent("Green text");
expect(output.textContent).not.toContain("\x1b");
});
it("has aria-live=polite for screen reader announcements", () => {
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
expect(screen.getByTestId("agent-output")).toHaveAttribute("aria-live", "polite");
});
});
// ==========================================
// Error message
// ==========================================
describe("error message", () => {
it("shows error message when status is error and errorMessage is set", () => {
const agent = makeAgent({
status: "error",
endedAt: Date.now(),
errorMessage: "Process crashed",
});
render((<AgentTerminal agent={agent} />) as ReactElement);
expect(screen.getByTestId("agent-error-message")).toHaveTextContent("Process crashed");
});
it("renders alert role for error message", () => {
const agent = makeAgent({
status: "error",
endedAt: Date.now(),
errorMessage: "OOM killed",
});
render((<AgentTerminal agent={agent} />) as ReactElement);
expect(screen.getByRole("alert")).toBeInTheDocument();
});
it("does not show error message when status is running", () => {
render((<AgentTerminal agent={makeAgent({ status: "running" })} />) as ReactElement);
expect(screen.queryByTestId("agent-error-message")).not.toBeInTheDocument();
});
it("does not show error message when status is error but errorMessage is absent", () => {
const agent = makeAgent({ status: "error", endedAt: Date.now() });
render((<AgentTerminal agent={agent} />) as ReactElement);
expect(screen.queryByTestId("agent-error-message")).not.toBeInTheDocument();
});
});
// ==========================================
// Copy to clipboard
// ==========================================
describe("copy to clipboard", () => {
it("renders the copy button", () => {
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
expect(screen.getByTestId("copy-button")).toBeInTheDocument();
});
it("copy button has aria-label='Copy agent output'", () => {
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
expect(screen.getByRole("button", { name: "Copy agent output" })).toBeInTheDocument();
});
it("calls clipboard.writeText with stripped output on click", async () => {
const agent = makeAgent({
outputLines: ["\x1b[32mLine 1\x1b[0m\n", "Line 2\n"],
});
render((<AgentTerminal agent={agent} />) as ReactElement);
await act(async () => {
fireEvent.click(screen.getByTestId("copy-button"));
await Promise.resolve();
});
expect(mockWriteText).toHaveBeenCalledWith("Line 1\nLine 2\n");
});
it("shows 'copied' text briefly after clicking copy", async () => {
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
await act(async () => {
fireEvent.click(screen.getByTestId("copy-button"));
await Promise.resolve();
});
expect(screen.getByTestId("copy-button")).toHaveTextContent("copied");
});
it("reverts copy button text after timeout", async () => {
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
await act(async () => {
fireEvent.click(screen.getByTestId("copy-button"));
await Promise.resolve();
});
act(() => {
vi.advanceTimersByTime(2500);
});
expect(screen.getByTestId("copy-button")).toHaveTextContent("copy");
});
});
// ==========================================
// Auto-scroll
// ==========================================
describe("auto-scroll", () => {
it("does not throw when outputLines changes", () => {
const agent = makeAgent({ outputLines: ["Line 1\n"] });
const { rerender } = render((<AgentTerminal agent={agent} />) as ReactElement);
expect(() => {
rerender(
(
<AgentTerminal agent={{ ...agent, outputLines: ["Line 1\n", "Line 2\n"] }} />
) as ReactElement
);
}).not.toThrow();
});
});
});

View File

@@ -0,0 +1,381 @@
"use client";
/**
* AgentTerminal component
*
* Read-only terminal view for displaying orchestrator agent output.
* Uses a <pre> element with monospace font rather than xterm.js because
* this is read-only agent stdout/stderr, not an interactive PTY.
*
* Features:
* - Displays accumulated output lines with basic ANSI color rendering
* - Status badge (spinning/checkmark/X) indicating agent lifecycle
* - Header bar with agent type, status, and elapsed duration
* - Auto-scrolls to bottom as new output arrives
* - Copy-to-clipboard button for full output
*/
import { useEffect, useRef, useState, useCallback } from "react";
import type { ReactElement, CSSProperties } from "react";
import type { AgentSession, AgentStatus } from "@/hooks/useAgentStream";
// ==========================================
// Types
// ==========================================
export interface AgentTerminalProps {
/** The agent session to display */
agent: AgentSession;
/** Optional CSS class name for the outer container */
className?: string;
/** Optional inline style for the outer container */
style?: CSSProperties;
}
// ==========================================
// ANSI color strip helper
// ==========================================
// Simple ANSI escape sequence stripper — produces readable plain text for <pre>.
// We strip rather than parse for security and simplicity in read-only display.
// eslint-disable-next-line no-control-regex
const ANSI_PATTERN = /\x1b\[[0-9;]*[mGKHF]/g;
function stripAnsi(text: string): string {
return text.replace(ANSI_PATTERN, "");
}
// ==========================================
// Duration helper
// ==========================================
function formatDuration(startedAt: number, endedAt?: number): string {
const elapsed = Math.floor(((endedAt ?? Date.now()) - startedAt) / 1000);
if (elapsed < 60) return `${elapsed.toString()}s`;
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
return `${minutes.toString()}m ${seconds.toString()}s`;
}
// ==========================================
// Status indicator
// ==========================================
interface StatusIndicatorProps {
status: AgentStatus;
}
function StatusIndicator({ status }: StatusIndicatorProps): ReactElement {
const baseStyle: CSSProperties = {
display: "inline-block",
width: 8,
height: 8,
borderRadius: "50%",
flexShrink: 0,
};
if (status === "running" || status === "spawning") {
return (
<span
data-testid="status-indicator"
data-status={status}
style={{
...baseStyle,
background: "var(--success)",
animation: "agentPulse 1.5s ease-in-out infinite",
}}
aria-label="Running"
/>
);
}
if (status === "completed") {
return (
<span
data-testid="status-indicator"
data-status={status}
style={{
...baseStyle,
background: "var(--muted)",
}}
aria-label="Completed"
/>
);
}
// error
return (
<span
data-testid="status-indicator"
data-status={status}
style={{
...baseStyle,
background: "var(--danger)",
}}
aria-label="Error"
/>
);
}
// ==========================================
// Status badge
// ==========================================
interface StatusBadgeProps {
status: AgentStatus;
}
function StatusBadge({ status }: StatusBadgeProps): ReactElement {
const colorMap: Record<AgentStatus, string> = {
spawning: "var(--warn)",
running: "var(--success)",
completed: "var(--muted)",
error: "var(--danger)",
};
const labelMap: Record<AgentStatus, string> = {
spawning: "spawning",
running: "running",
completed: "completed",
error: "error",
};
return (
<span
data-testid="status-badge"
style={{
fontSize: "0.65rem",
fontFamily: "var(--mono)",
color: colorMap[status],
border: `1px solid ${colorMap[status]}`,
borderRadius: 3,
padding: "1px 5px",
lineHeight: 1.6,
letterSpacing: "0.03em",
flexShrink: 0,
}}
>
{labelMap[status]}
</span>
);
}
// ==========================================
// Component
// ==========================================
/**
* AgentTerminal renders accumulated agent output in a scrollable pre block.
* It is intentionally read-only — no keyboard input is accepted.
*/
export function AgentTerminal({ agent, className = "", style }: AgentTerminalProps): ReactElement {
const outputRef = useRef<HTMLPreElement>(null);
const [copied, setCopied] = useState(false);
const [tick, setTick] = useState(0);
// ==========================================
// Duration ticker — only runs while active
// ==========================================
useEffect(() => {
if (agent.status === "running" || agent.status === "spawning") {
const id = setInterval(() => {
setTick((t) => t + 1);
}, 1000);
return (): void => {
clearInterval(id);
};
}
return undefined;
}, [agent.status]);
// Consume tick to avoid unused-var lint
void tick;
// ==========================================
// Auto-scroll to bottom on new output
// ==========================================
useEffect(() => {
const el = outputRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
}
}, [agent.outputLines]);
// ==========================================
// Copy to clipboard
// ==========================================
const handleCopy = useCallback((): void => {
const text = agent.outputLines.map(stripAnsi).join("");
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
});
}, [agent.outputLines]);
// ==========================================
// Styles
// ==========================================
const containerStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
height: "100%",
background: "var(--bg-deep)",
overflow: "hidden",
...style,
};
const headerStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
background: "var(--bg-deep)",
};
const titleStyle: CSSProperties = {
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: "var(--text)",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const durationStyle: CSSProperties = {
fontSize: "0.65rem",
fontFamily: "var(--mono)",
color: "var(--muted)",
flexShrink: 0,
};
const outputStyle: CSSProperties = {
flex: 1,
overflow: "auto",
margin: 0,
padding: "8px 12px",
fontFamily: "var(--mono)",
fontSize: "0.75rem",
lineHeight: 1.5,
color: "var(--text)",
background: "var(--bg-deep)",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
};
const copyButtonStyle: CSSProperties = {
background: "transparent",
border: "1px solid var(--border)",
borderRadius: 3,
color: copied ? "var(--success)" : "var(--muted)",
cursor: "pointer",
fontSize: "0.65rem",
fontFamily: "var(--mono)",
padding: "2px 6px",
flexShrink: 0,
};
const duration = formatDuration(agent.startedAt, agent.endedAt);
return (
<div
className={className}
style={containerStyle}
role="region"
aria-label={`Agent output: ${agent.agentType}`}
data-testid="agent-terminal"
data-agent-id={agent.agentId}
>
{/* Header */}
<div style={headerStyle} data-testid="agent-terminal-header">
<StatusIndicator status={agent.status} />
<span style={titleStyle} data-testid="agent-type-label">
{agent.agentType}
{agent.jobId !== undefined ? ` · ${agent.jobId}` : ""}
</span>
<StatusBadge status={agent.status} />
<span style={durationStyle} data-testid="agent-duration">
{duration}
</span>
{/* Copy button */}
<button
aria-label="Copy agent output"
style={copyButtonStyle}
onClick={handleCopy}
data-testid="copy-button"
onMouseEnter={(e): void => {
if (!copied) {
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
(e.currentTarget as HTMLButtonElement).style.borderColor = "var(--text-2)";
}
}}
onMouseLeave={(e): void => {
if (!copied) {
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
(e.currentTarget as HTMLButtonElement).style.borderColor = "var(--border)";
}
}}
>
{copied ? "copied" : "copy"}
</button>
</div>
{/* Output area */}
<pre
ref={outputRef}
style={outputStyle}
data-testid="agent-output"
aria-label="Agent output log"
aria-live="polite"
aria-atomic="false"
>
{agent.outputLines.length === 0 ? (
<span style={{ color: "var(--muted)" }}>
{agent.status === "spawning" ? "Spawning agent..." : "Waiting for output..."}
</span>
) : (
agent.outputLines.map(stripAnsi).join("")
)}
</pre>
{/* Error message overlay */}
{agent.status === "error" && agent.errorMessage !== undefined && (
<div
style={{
padding: "4px 12px",
fontSize: "0.7rem",
fontFamily: "var(--mono)",
color: "var(--danger)",
background: "var(--bg-deep)",
borderTop: "1px solid var(--border)",
flexShrink: 0,
}}
data-testid="agent-error-message"
role="alert"
>
Error: {agent.errorMessage}
</div>
)}
{/* Pulse animation keyframes — injected inline via style tag for zero deps */}
<style>{`
@keyframes agentPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,581 @@
/**
* @file TerminalPanel.test.tsx
* @description Unit tests for the TerminalPanel component — multi-tab scenarios
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { ReactElement } from "react";
// ==========================================
// Mocks
// ==========================================
// Mock XTerminal to avoid xterm.js DOM dependencies in panel tests
vi.mock("./XTerminal", () => ({
XTerminal: vi.fn(
({
sessionId,
isVisible,
sessionStatus,
}: {
sessionId: string;
isVisible: boolean;
sessionStatus: string;
}) => (
<div
data-testid="mock-xterminal"
data-session-id={sessionId}
data-visible={isVisible ? "true" : "false"}
data-status={sessionStatus}
/>
)
),
}));
// Mock AgentTerminal to avoid complexity in panel tests
vi.mock("./AgentTerminal", () => ({
AgentTerminal: vi.fn(
({ agent }: { agent: { agentId: string; agentType: string; status: string } }) => (
<div
data-testid="mock-agent-terminal"
data-agent-id={agent.agentId}
data-agent-type={agent.agentType}
data-status={agent.status}
/>
)
),
}));
// Mock useTerminalSessions
const mockCreateSession = vi.fn();
const mockCloseSession = vi.fn();
const mockRenameSession = vi.fn();
const mockSetActiveSession = vi.fn();
const mockSendInput = vi.fn();
const mockResize = vi.fn();
const mockRegisterOutputCallback = vi.fn(() => vi.fn());
// Mutable state for the mock — tests update these
let mockSessions = new Map<
string,
{
sessionId: string;
name: string;
status: "active" | "exited";
exitCode?: number;
}
>();
let mockActiveSessionId: string | null = null;
let mockIsConnected = false;
let mockConnectionError: string | null = null;
vi.mock("@/hooks/useTerminalSessions", () => ({
useTerminalSessions: vi.fn(() => ({
sessions: mockSessions,
activeSessionId: mockActiveSessionId,
isConnected: mockIsConnected,
connectionError: mockConnectionError,
createSession: mockCreateSession,
closeSession: mockCloseSession,
renameSession: mockRenameSession,
setActiveSession: mockSetActiveSession,
sendInput: mockSendInput,
resize: mockResize,
registerOutputCallback: mockRegisterOutputCallback,
})),
}));
// Mock useAgentStream
const mockDismissAgent = vi.fn();
let mockAgents = new Map<
string,
{
agentId: string;
agentType: string;
status: "spawning" | "running" | "completed" | "error";
outputLines: string[];
startedAt: number;
}
>();
let mockAgentStreamConnected = false;
vi.mock("@/hooks/useAgentStream", () => ({
useAgentStream: vi.fn(() => ({
agents: mockAgents,
isConnected: mockAgentStreamConnected,
connectionError: null,
dismissAgent: mockDismissAgent,
})),
}));
import { TerminalPanel } from "./TerminalPanel";
// ==========================================
// Helpers
// ==========================================
function setTwoSessions(): void {
mockSessions = new Map([
["session-1", { sessionId: "session-1", name: "Terminal 1", status: "active" }],
["session-2", { sessionId: "session-2", name: "Terminal 2", status: "active" }],
]);
mockActiveSessionId = "session-1";
}
// ==========================================
// Tests
// ==========================================
describe("TerminalPanel", () => {
const onClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockSessions = new Map();
mockActiveSessionId = null;
mockIsConnected = false;
mockConnectionError = null;
mockRegisterOutputCallback.mockReturnValue(vi.fn());
mockAgents = new Map();
mockAgentStreamConnected = false;
});
// ==========================================
// Rendering
// ==========================================
describe("rendering", () => {
it("renders the terminal panel", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("region", { name: "Terminal panel" })).toBeInTheDocument();
});
it("renders with height 280 when open", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const panel = screen.getByRole("region", { name: "Terminal panel" });
expect(panel).toHaveStyle({ height: "280px" });
});
it("renders with height 0 when closed", () => {
const { container } = render(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
);
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
expect(panel).toHaveStyle({ height: "0px" });
});
it("renders empty state when no sessions exist", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
// No XTerminal instances should be mounted
expect(screen.queryByTestId("mock-xterminal")).not.toBeInTheDocument();
});
it("shows connecting message in empty state when not connected", () => {
mockIsConnected = false;
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByText("Connecting...")).toBeInTheDocument();
});
it("shows creating message in empty state when connected", () => {
mockIsConnected = true;
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByText("Creating terminal...")).toBeInTheDocument();
});
});
// ==========================================
// Tab bar from sessions
// ==========================================
describe("tab bar", () => {
it("renders a tab for each session", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument();
});
it("marks the active session tab as selected", () => {
setTwoSessions();
mockActiveSessionId = "session-2";
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute(
"aria-selected",
"true"
);
expect(screen.getByRole("tab", { name: "Terminal 1" })).toHaveAttribute(
"aria-selected",
"false"
);
});
it("calls setActiveSession when a tab is clicked", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.click(screen.getByRole("tab", { name: "Terminal 2" }));
expect(mockSetActiveSession).toHaveBeenCalledWith("session-2");
});
it("has tablist role on the tab bar", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("tablist")).toBeInTheDocument();
});
});
// ==========================================
// New tab button
// ==========================================
describe("new tab button", () => {
it("renders the new tab button", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "New terminal tab" })).toBeInTheDocument();
});
it("calls createSession when new tab button is clicked", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.click(screen.getByRole("button", { name: "New terminal tab" }));
expect(mockCreateSession).toHaveBeenCalledWith(
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ name: expect.any(String) })
);
});
});
// ==========================================
// Per-tab close button
// ==========================================
describe("per-tab close button", () => {
it("renders a close button for each tab", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "Close Terminal 1" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Close Terminal 2" })).toBeInTheDocument();
});
it("calls closeSession with the correct sessionId when tab close is clicked", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.click(screen.getByRole("button", { name: "Close Terminal 1" }));
expect(mockCloseSession).toHaveBeenCalledWith("session-1");
});
});
// ==========================================
// Panel close button
// ==========================================
describe("panel close button", () => {
it("renders the close panel button", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument();
});
it("calls onClose when close panel button is clicked", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.click(screen.getByRole("button", { name: "Close terminal" }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
// ==========================================
// Multi-tab XTerminal rendering
// ==========================================
describe("multi-tab terminal rendering", () => {
it("renders an XTerminal for each session", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const terminals = screen.getAllByTestId("mock-xterminal");
expect(terminals).toHaveLength(2);
});
it("shows the active session terminal as visible", () => {
setTwoSessions();
mockActiveSessionId = "session-1";
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const terminal1 = screen
.getAllByTestId("mock-xterminal")
.find((el) => el.getAttribute("data-session-id") === "session-1");
expect(terminal1).toHaveAttribute("data-visible", "true");
});
it("hides inactive session terminals", () => {
setTwoSessions();
mockActiveSessionId = "session-1";
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const terminal2 = screen
.getAllByTestId("mock-xterminal")
.find((el) => el.getAttribute("data-session-id") === "session-2");
expect(terminal2).toHaveAttribute("data-visible", "false");
});
it("passes sessionStatus to XTerminal", () => {
mockSessions = new Map([
[
"session-1",
{ sessionId: "session-1", name: "Terminal 1", status: "exited", exitCode: 0 },
],
]);
mockActiveSessionId = "session-1";
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const terminal = screen.getByTestId("mock-xterminal");
expect(terminal).toHaveAttribute("data-status", "exited");
});
it("passes isVisible=false to all terminals when panel is closed", () => {
setTwoSessions();
const { container } = render(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
);
const terminals = container.querySelectorAll('[data-testid="mock-xterminal"]');
terminals.forEach((terminal) => {
expect(terminal).toHaveAttribute("data-visible", "false");
});
});
});
// ==========================================
// Inline tab rename
// ==========================================
describe("tab rename", () => {
it("shows a rename input when a tab is double-clicked", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
expect(screen.getByTestId("tab-rename-input")).toBeInTheDocument();
});
it("calls renameSession when rename input loses focus", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
const input = screen.getByTestId("tab-rename-input");
fireEvent.change(input, { target: { value: "Custom Shell" } });
fireEvent.blur(input);
expect(mockRenameSession).toHaveBeenCalledWith("session-1", "Custom Shell");
});
it("calls renameSession when Enter is pressed in the rename input", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
const input = screen.getByTestId("tab-rename-input");
fireEvent.change(input, { target: { value: "New Name" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(mockRenameSession).toHaveBeenCalledWith("session-1", "New Name");
});
it("cancels rename when Escape is pressed", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
const input = screen.getByTestId("tab-rename-input");
fireEvent.change(input, { target: { value: "Abandoned Name" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(mockRenameSession).not.toHaveBeenCalled();
expect(screen.queryByTestId("tab-rename-input")).not.toBeInTheDocument();
});
});
// ==========================================
// Connection error banner
// ==========================================
describe("connection error", () => {
it("shows a connection error banner when connectionError is set", () => {
mockConnectionError = "WebSocket connection failed";
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const alert = screen.getByRole("alert");
expect(alert).toBeInTheDocument();
expect(alert).toHaveTextContent(/WebSocket connection failed/);
});
it("does not show the error banner when connectionError is null", () => {
mockConnectionError = null;
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
// ==========================================
// Accessibility
// ==========================================
describe("accessibility", () => {
it("has aria-hidden=true when closed", () => {
const { container } = render(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
);
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
expect(panel).toHaveAttribute("aria-hidden", "true");
});
it("has aria-hidden=false when open", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const panel = screen.getByRole("region", { name: "Terminal panel" });
expect(panel).toHaveAttribute("aria-hidden", "false");
});
});
// ==========================================
// Auto-create session
// ==========================================
describe("auto-create first session", () => {
it("calls createSession when connected and no sessions exist", () => {
mockIsConnected = true;
mockSessions = new Map();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(mockCreateSession).toHaveBeenCalledWith(
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ name: expect.any(String) })
);
});
it("does not call createSession when sessions already exist", () => {
mockIsConnected = true;
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(mockCreateSession).not.toHaveBeenCalled();
});
it("does not call createSession when not connected", () => {
mockIsConnected = false;
mockSessions = new Map();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(mockCreateSession).not.toHaveBeenCalled();
});
it("does not call createSession when panel is closed", () => {
mockIsConnected = true;
mockSessions = new Map();
render((<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement);
expect(mockCreateSession).not.toHaveBeenCalled();
});
});
// ==========================================
// Agent tab integration
// ==========================================
describe("agent tab integration", () => {
function setOneAgent(status: "spawning" | "running" | "completed" | "error" = "running"): void {
mockAgents = new Map([
[
"agent-1",
{
agentId: "agent-1",
agentType: "worker",
status,
outputLines: ["Hello from agent\n"],
startedAt: Date.now() - 3000,
},
],
]);
}
it("renders an agent tab when an agent is active", () => {
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getAllByTestId("agent-tab")).toHaveLength(1);
});
it("renders no agent tabs when agents map is empty", () => {
mockAgents = new Map();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.queryByTestId("agent-tab")).not.toBeInTheDocument();
});
it("agent tab button has the agent type as label", () => {
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument();
});
it("agent tab has role=tab", () => {
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument();
});
it("shows dismiss button for completed agents", () => {
setOneAgent("completed");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument();
});
it("shows dismiss button for error agents", () => {
setOneAgent("error");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument();
});
it("does not show dismiss button for running agents", () => {
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(
screen.queryByRole("button", { name: "Dismiss worker agent" })
).not.toBeInTheDocument();
});
it("does not show dismiss button for spawning agents", () => {
setOneAgent("spawning");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(
screen.queryByRole("button", { name: "Dismiss worker agent" })
).not.toBeInTheDocument();
});
it("calls dismissAgent when dismiss button is clicked", () => {
setOneAgent("completed");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.click(screen.getByRole("button", { name: "Dismiss worker agent" }));
expect(mockDismissAgent).toHaveBeenCalledWith("agent-1");
});
it("renders AgentTerminal when agent tab is active", () => {
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
// Click the agent tab to make it active
fireEvent.click(screen.getByRole("tab", { name: "Agent: worker" }));
// AgentTerminal should be rendered (mock shows mock-agent-terminal)
expect(screen.getByTestId("mock-agent-terminal")).toBeInTheDocument();
});
it("shows a divider between terminal and agent tabs", () => {
setTwoSessions();
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
// The divider div is aria-hidden; check it's present in the DOM
const tablist = screen.getByRole("tablist");
const divider = tablist.querySelector('[aria-hidden="true"][style*="width: 1"]');
expect(divider).toBeInTheDocument();
});
it("agent tabs show correct data-agent-status", () => {
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const tab = screen.getByTestId("agent-tab");
expect(tab).toHaveAttribute("data-agent-status", "running");
});
it("empty state not shown when agents exist but no terminal sessions", () => {
mockSessions = new Map();
setOneAgent("running");
mockIsConnected = false;
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.queryByText("Connecting...")).not.toBeInTheDocument();
});
});
});

View File

@@ -1,80 +1,191 @@
import type { ReactElement, CSSProperties } from "react";
"use client";
export interface TerminalLine {
type: "prompt" | "command" | "output" | "error" | "warning" | "success";
content: string;
}
/**
* TerminalPanel
*
* Multi-tab terminal panel. Manages multiple PTY sessions via useTerminalSessions,
* rendering one XTerminal per session and keeping all instances mounted (for
* scrollback preservation) while switching visibility with display:none.
*
* Also renders read-only agent output tabs from the orchestrator SSE stream
* via useAgentStream. Agent tabs are automatically added when agents are active
* and can be dismissed when completed or errored.
*
* Features:
* - "+" button to open a new terminal tab
* - Per-tab close button (terminal) / dismiss button (agent)
* - Double-click tab label for inline rename (terminal tabs only)
* - Auto-creates the first terminal session on connect
* - Connection error state
* - Agent tabs: read-only, auto-appear, dismissable
*/
export interface TerminalTab {
id: string;
label: string;
}
import { useState, useEffect, useRef, useCallback } from "react";
import type { ReactElement, CSSProperties, KeyboardEvent } from "react";
import { XTerminal } from "./XTerminal";
import { AgentTerminal } from "./AgentTerminal";
import { useTerminalSessions } from "@/hooks/useTerminalSessions";
import { useAgentStream } from "@/hooks/useAgentStream";
// ==========================================
// Types
// ==========================================
export interface TerminalPanelProps {
/** Whether the panel is visible */
open: boolean;
/** Called when the user closes the panel */
onClose: () => void;
tabs?: TerminalTab[];
activeTab?: string;
onTabChange?: (id: string) => void;
lines?: TerminalLine[];
/** Authentication token for the WebSocket connection */
token?: string;
/** Optional CSS class name */
className?: string;
}
const defaultTabs: TerminalTab[] = [
{ id: "main", label: "main" },
{ id: "build", label: "build" },
{ id: "logs", label: "logs" },
];
const blinkKeyframes = `
@keyframes ms-terminal-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`;
let blinkStyleInjected = false;
function ensureBlinkStyle(): void {
if (blinkStyleInjected || typeof document === "undefined") return;
const styleEl = document.createElement("style");
styleEl.textContent = blinkKeyframes;
document.head.appendChild(styleEl);
blinkStyleInjected = true;
}
function getLineColor(type: TerminalLine["type"]): string {
switch (type) {
case "prompt":
return "var(--success)";
case "command":
return "var(--text-2)";
case "output":
return "var(--muted)";
case "error":
return "var(--danger)";
case "warning":
return "var(--warn)";
case "success":
return "var(--success)";
default:
return "var(--muted)";
}
}
// ==========================================
// Component
// ==========================================
export function TerminalPanel({
open,
onClose,
tabs,
activeTab,
onTabChange,
lines = [],
token = "",
className = "",
}: TerminalPanelProps): ReactElement {
ensureBlinkStyle();
const {
sessions,
activeSessionId,
isConnected,
connectionError,
createSession,
closeSession,
renameSession,
setActiveSession,
sendInput,
resize,
registerOutputCallback,
} = useTerminalSessions({ token });
const resolvedTabs = tabs ?? defaultTabs;
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
// ==========================================
// Agent stream
// ==========================================
const { agents, dismissAgent } = useAgentStream();
// ==========================================
// Active tab state (terminal session OR agent)
// "terminal:<sessionId>" or "agent:<agentId>"
// ==========================================
type TabId = string; // prefix-qualified: "terminal:<id>" or "agent:<id>"
const [activeTabId, setActiveTabId] = useState<TabId | null>(null);
// Sync activeTabId with the terminal session activeSessionId when no agent tab is selected
useEffect(() => {
setActiveTabId((prev) => {
// If an agent tab is active, don't clobber it
if (prev?.startsWith("agent:")) return prev;
// Reflect active terminal session
if (activeSessionId !== null) return `terminal:${activeSessionId}`;
return prev;
});
}, [activeSessionId]);
// If the active agent tab is dismissed, fall back to the terminal session
useEffect(() => {
if (activeTabId?.startsWith("agent:")) {
const agentId = activeTabId.slice("agent:".length);
if (!agents.has(agentId)) {
setActiveTabId(activeSessionId !== null ? `terminal:${activeSessionId}` : null);
}
}
}, [agents, activeTabId, activeSessionId]);
// ==========================================
// Inline rename state
// ==========================================
const [editingTabId, setEditingTabId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const editInputRef = useRef<HTMLInputElement>(null);
// Focus the rename input when editing starts
useEffect(() => {
if (editingTabId !== null) {
editInputRef.current?.select();
}
}, [editingTabId]);
// ==========================================
// Auto-create first session on connect
// ==========================================
useEffect(() => {
if (open && isConnected && sessions.size === 0) {
createSession({ name: "Terminal 1" });
}
}, [open, isConnected, sessions.size, createSession]);
// ==========================================
// Tab rename helpers
// ==========================================
const commitRename = useCallback((): void => {
if (editingTabId !== null) {
const trimmed = editingName.trim();
if (trimmed.length > 0) {
renameSession(editingTabId, trimmed);
}
setEditingTabId(null);
setEditingName("");
}
}, [editingTabId, editingName, renameSession]);
const handleTabDoubleClick = useCallback((sessionId: string, currentName: string): void => {
setEditingTabId(sessionId);
setEditingName(currentName);
}, []);
const handleRenameKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") {
commitRename();
} else if (e.key === "Escape") {
setEditingTabId(null);
setEditingName("");
}
},
[commitRename]
);
// ==========================================
// Session control helpers
// ==========================================
const handleCreateTab = useCallback((): void => {
const tabNumber = sessions.size + 1;
createSession({ name: `Terminal ${tabNumber.toString()}` });
}, [sessions.size, createSession]);
const handleCloseTab = useCallback(
(sessionId: string): void => {
closeSession(sessionId);
},
[closeSession]
);
const handleRestart = useCallback(
(sessionId: string, name: string): void => {
closeSession(sessionId);
createSession({ name });
},
[closeSession, createSession]
);
// ==========================================
// Styles
// ==========================================
const panelStyle: CSSProperties = {
background: "var(--bg-deep)",
@@ -99,33 +210,40 @@ export function TerminalPanel({
const tabBarStyle: CSSProperties = {
display: "flex",
gap: 2,
alignItems: "center",
flex: 1,
overflow: "hidden",
};
const actionsStyle: CSSProperties = {
marginLeft: "auto",
display: "flex",
gap: 4,
alignItems: "center",
};
const bodyStyle: CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "10px 16px",
fontFamily: "var(--mono)",
fontSize: "0.78rem",
lineHeight: 1.6,
overflow: "hidden",
display: "flex",
flexDirection: "column",
minHeight: 0,
position: "relative",
};
const cursorStyle: CSSProperties = {
display: "inline-block",
width: 7,
height: 14,
background: "var(--success)",
marginLeft: 2,
animation: "ms-terminal-blink 1s step-end infinite",
verticalAlign: "text-bottom",
// ==========================================
// Agent status dot color
// ==========================================
const agentDotColor = (status: string): string => {
if (status === "running" || status === "spawning") return "var(--success)";
if (status === "error") return "var(--danger)";
return "var(--muted)";
};
// ==========================================
// Render
// ==========================================
return (
<div
className={className}
@@ -138,50 +256,315 @@ export function TerminalPanel({
<div style={headerStyle}>
{/* Tab bar */}
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
{resolvedTabs.map((tab) => {
const isActive = tab.id === resolvedActiveTab;
{/* ---- Terminal session tabs ---- */}
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
const tabKey = `terminal:${sessionId}`;
const isActive = tabKey === activeTabId;
const isEditing = sessionId === editingTabId;
const tabStyle: CSSProperties = {
padding: "3px 10px",
display: "flex",
alignItems: "center",
gap: 4,
padding: "3px 6px 3px 10px",
borderRadius: 4,
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: isActive ? "var(--success)" : "var(--muted)",
cursor: "pointer",
background: isActive ? "var(--surface)" : "transparent",
border: "none",
outline: "none",
flexShrink: 0,
};
return (
<div key={tabKey} style={tabStyle}>
{isEditing ? (
<input
ref={editInputRef}
value={editingName}
onChange={(e): void => {
setEditingName(e.target.value);
}}
onBlur={commitRename}
onKeyDown={handleRenameKeyDown}
data-testid="tab-rename-input"
style={{
background: "transparent",
border: "none",
outline: "1px solid var(--primary)",
borderRadius: 2,
fontFamily: "var(--mono)",
fontSize: "0.75rem",
color: "var(--text)",
width: `${Math.max(editingName.length, 4).toString()}ch`,
padding: "0 2px",
}}
aria-label="Rename terminal tab"
/>
) : (
<button
key={tab.id}
role="tab"
aria-selected={isActive}
style={tabStyle}
style={{
background: "transparent",
border: "none",
outline: "none",
fontFamily: "var(--mono)",
fontSize: "0.75rem",
color: isActive ? "var(--success)" : "var(--muted)",
cursor: "pointer",
padding: 0,
}}
onClick={(): void => {
onTabChange?.(tab.id);
setActiveTabId(tabKey);
setActiveSession(sessionId);
}}
onDoubleClick={(): void => {
handleTabDoubleClick(sessionId, sessionInfo.name);
}}
onMouseEnter={(e): void => {
if (!isActive) {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
}
}}
onMouseLeave={(e): void => {
if (!isActive) {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}
}}
aria-label={sessionInfo.name}
>
{tab.label}
{sessionInfo.name}
</button>
)}
{/* Per-tab close button */}
<button
aria-label={`Close ${sessionInfo.name}`}
style={{
width: 16,
height: 16,
borderRadius: 3,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
cursor: "pointer",
background: "transparent",
border: "none",
outline: "none",
padding: 0,
flexShrink: 0,
}}
onClick={(): void => {
handleCloseTab(sessionId);
}}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path
d="M1 1L7 7M7 1L1 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
);
})}
{/* New tab button */}
<button
aria-label="New terminal tab"
style={{
width: 22,
height: 22,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
cursor: "pointer",
background: "transparent",
border: "none",
outline: "none",
padding: 0,
flexShrink: 0,
}}
onClick={handleCreateTab}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
{/* Plus icon */}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M6 1V11M1 6H11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
{/* ---- Agent section divider (only when agents exist) ---- */}
{agents.size > 0 && (
<div
aria-hidden="true"
style={{
width: 1,
height: 16,
background: "var(--border)",
marginLeft: 6,
marginRight: 4,
flexShrink: 0,
}}
/>
)}
{/* ---- Agent tabs ---- */}
{[...agents.entries()].map(([agentId, agentSession]) => {
const tabKey = `agent:${agentId}`;
const isActive = tabKey === activeTabId;
const canDismiss =
agentSession.status === "completed" || agentSession.status === "error";
const agentTabStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 4,
padding: "3px 6px 3px 8px",
borderRadius: 4,
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: isActive ? "var(--text)" : "var(--muted)",
background: isActive ? "var(--surface)" : "transparent",
border: "none",
outline: "none",
flexShrink: 0,
};
return (
<div
key={tabKey}
style={agentTabStyle}
data-testid="agent-tab"
data-agent-id={agentId}
data-agent-status={agentSession.status}
>
{/* Status dot */}
<span
aria-hidden="true"
style={{
display: "inline-block",
width: 6,
height: 6,
borderRadius: "50%",
background: agentDotColor(agentSession.status),
flexShrink: 0,
}}
/>
{/* Agent tab button — read-only, no rename */}
<button
role="tab"
aria-selected={isActive}
style={{
background: "transparent",
border: "none",
outline: "none",
fontFamily: "var(--mono)",
fontSize: "0.75rem",
color: isActive ? "var(--text)" : "var(--muted)",
cursor: "pointer",
padding: 0,
maxWidth: 100,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
onClick={(): void => {
setActiveTabId(tabKey);
}}
onMouseEnter={(e): void => {
if (!isActive) {
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
}
}}
onMouseLeave={(e): void => {
if (!isActive) {
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}
}}
aria-label={`Agent: ${agentSession.agentType}`}
>
{agentSession.agentType}
</button>
{/* Dismiss button — only for completed/error agents */}
{canDismiss && (
<button
aria-label={`Dismiss ${agentSession.agentType} agent`}
style={{
width: 16,
height: 16,
borderRadius: 3,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
cursor: "pointer",
background: "transparent",
border: "none",
outline: "none",
padding: 0,
flexShrink: 0,
}}
onClick={(): void => {
dismissAgent(agentId);
}}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path
d="M1 1L7 7M7 1L1 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
)}
</div>
);
})}
</div>
{/* Action buttons */}
<div style={actionsStyle}>
{/* Close panel button */}
<button
aria-label="Close terminal"
style={{
@@ -208,7 +591,7 @@ export function TerminalPanel({
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
{/* Close icon — simple X using SVG */}
{/* Close icon */}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M1 1L11 11M11 1L1 11"
@@ -221,34 +604,90 @@ export function TerminalPanel({
</div>
</div>
{/* Body */}
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
{lines.map((line, index) => {
const isLast = index === lines.length - 1;
const lineStyle: CSSProperties = {
display: "flex",
gap: 8,
};
const contentStyle: CSSProperties = {
color: getLineColor(line.type),
{/* Connection error banner */}
{connectionError !== null && (
<div
role="alert"
style={{
padding: "4px 16px",
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: "var(--danger)",
backgroundColor: "var(--bg-deep)",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
}}
>
Connection error: {connectionError}
</div>
)}
{/* Terminal body — keep all XTerminal instances mounted for scrollback */}
<div style={bodyStyle}>
{/* ---- Terminal session panels ---- */}
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
const tabKey = `terminal:${sessionId}`;
const isActive = tabKey === activeTabId;
const termStyle: CSSProperties = {
display: isActive ? "flex" : "none",
flex: 1,
flexDirection: "column",
minHeight: 0,
};
return (
<div key={index} style={lineStyle}>
<span style={contentStyle}>
{line.content}
{isLast && <span aria-hidden="true" style={cursorStyle} />}
</span>
<div key={tabKey} style={termStyle}>
<XTerminal
sessionId={sessionId}
sendInput={sendInput}
resize={resize}
closeSession={closeSession}
registerOutputCallback={registerOutputCallback}
isConnected={isConnected}
sessionStatus={sessionInfo.status}
{...(sessionInfo.exitCode !== undefined ? { exitCode: sessionInfo.exitCode } : {})}
isVisible={isActive && open}
onRestart={(): void => {
handleRestart(sessionId, sessionInfo.name);
}}
style={{ flex: 1, minHeight: 0 }}
/>
</div>
);
})}
{/* Show cursor even when no lines */}
{lines.length === 0 && (
<div style={{ display: "flex", gap: 8 }}>
<span style={{ color: "var(--success)" }}>
<span aria-hidden="true" style={cursorStyle} />
</span>
{/* ---- Agent session panels ---- */}
{[...agents.entries()].map(([agentId, agentSession]) => {
const tabKey = `agent:${agentId}`;
const isActive = tabKey === activeTabId;
const agentPanelStyle: CSSProperties = {
display: isActive ? "flex" : "none",
flex: 1,
flexDirection: "column",
minHeight: 0,
};
return (
<div key={tabKey} style={agentPanelStyle}>
<AgentTerminal agent={agentSession} style={{ flex: 1, minHeight: 0 }} />
</div>
);
})}
{/* Empty state — show only when no terminal sessions AND no agent sessions */}
{sessions.size === 0 && agents.size === 0 && (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
fontSize: "0.75rem",
fontFamily: "var(--mono)",
}}
>
{isConnected ? "Creating terminal..." : (connectionError ?? "Connecting...")}
</div>
)}
</div>

View File

@@ -0,0 +1,270 @@
/**
* @file XTerminal.test.tsx
* @description Unit tests for the XTerminal component
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { ReactElement } from "react";
// ==========================================
// Mocks — set up before importing components
// ==========================================
// Mock xterm packages — they require a DOM canvas not available in jsdom
const mockTerminalDispose = vi.fn();
const mockTerminalWrite = vi.fn();
const mockTerminalClear = vi.fn();
const mockTerminalOpen = vi.fn();
const mockOnData = vi.fn((_handler: (data: string) => void) => ({ dispose: vi.fn() }));
const mockLoadAddon = vi.fn();
let mockTerminalCols = 80;
let mockTerminalRows = 24;
const MockTerminal = vi.fn(function MockTerminalConstructor(
this: Record<string, unknown>,
_options: unknown
) {
this.open = mockTerminalOpen;
this.loadAddon = mockLoadAddon;
this.onData = mockOnData;
this.write = mockTerminalWrite;
this.clear = mockTerminalClear;
this.dispose = mockTerminalDispose;
this.options = {};
Object.defineProperty(this, "cols", {
get: () => mockTerminalCols,
configurable: true,
});
Object.defineProperty(this, "rows", {
get: () => mockTerminalRows,
configurable: true,
});
});
const mockFitAddonFit = vi.fn();
const MockFitAddon = vi.fn(function MockFitAddonConstructor(this: Record<string, unknown>) {
this.fit = mockFitAddonFit;
});
const MockWebLinksAddon = vi.fn(function MockWebLinksAddonConstructor(
this: Record<string, unknown>
) {
// no-op
});
vi.mock("@xterm/xterm", () => ({
Terminal: MockTerminal,
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: MockFitAddon,
}));
vi.mock("@xterm/addon-web-links", () => ({
WebLinksAddon: MockWebLinksAddon,
}));
// Mock the CSS import
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
// Mock ResizeObserver
const mockObserve = vi.fn();
const mockUnobserve = vi.fn();
const mockDisconnect = vi.fn();
vi.stubGlobal(
"ResizeObserver",
vi.fn(function MockResizeObserver(this: Record<string, unknown>, _callback: unknown) {
this.observe = mockObserve;
this.unobserve = mockUnobserve;
this.disconnect = mockDisconnect;
})
);
// Mock MutationObserver
const mockMutationObserve = vi.fn();
const mockMutationDisconnect = vi.fn();
vi.stubGlobal(
"MutationObserver",
vi.fn(function MockMutationObserver(this: Record<string, unknown>, _callback: unknown) {
this.observe = mockMutationObserve;
this.disconnect = mockMutationDisconnect;
})
);
// ==========================================
// Import component after mocks are set up
// ==========================================
import { XTerminal } from "./XTerminal";
// ==========================================
// Default props factory
// ==========================================
const mockSendInput = vi.fn();
const mockResize = vi.fn();
const mockCloseSession = vi.fn();
const mockRegisterOutputCallback = vi.fn(() => vi.fn()); // returns unsubscribe fn
const mockOnRestart = vi.fn();
function makeDefaultProps(
overrides: Partial<Parameters<typeof XTerminal>[0]> = {}
): Parameters<typeof XTerminal>[0] {
return {
sessionId: "session-test",
sendInput: mockSendInput,
resize: mockResize,
closeSession: mockCloseSession,
registerOutputCallback: mockRegisterOutputCallback,
isConnected: false,
sessionStatus: "active" as const,
...overrides,
};
}
// ==========================================
// Tests
// ==========================================
describe("XTerminal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTerminalCols = 80;
mockTerminalRows = 24;
mockRegisterOutputCallback.mockReturnValue(vi.fn());
});
afterEach(() => {
vi.clearAllMocks();
});
// ==========================================
// Rendering
// ==========================================
describe("rendering", () => {
it("renders the terminal container", () => {
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
});
it("renders the xterm viewport div", () => {
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument();
});
it("applies the className prop to the container", () => {
render((<XTerminal {...makeDefaultProps()} className="custom-class" />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class");
});
it("sets data-session-id on the container", () => {
render((<XTerminal {...makeDefaultProps({ sessionId: "my-session" })} />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toHaveAttribute(
"data-session-id",
"my-session"
);
});
it("shows connecting message when not connected and session is active", () => {
render((<XTerminal {...makeDefaultProps({ isConnected: false })} />) as ReactElement);
expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument();
});
it("does not show connecting message when connected", () => {
render((<XTerminal {...makeDefaultProps({ isConnected: true })} />) as ReactElement);
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
});
it("does not show connecting message when session has exited", () => {
render(
(
<XTerminal {...makeDefaultProps({ isConnected: false, sessionStatus: "exited" })} />
) as ReactElement
);
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
});
});
// ==========================================
// Exit overlay
// ==========================================
describe("exit overlay", () => {
it("shows restart button when session has exited", () => {
render((<XTerminal {...makeDefaultProps({ sessionStatus: "exited" })} />) as ReactElement);
expect(screen.getByRole("button", { name: /restart terminal/i })).toBeInTheDocument();
});
it("does not show restart button when session is active", () => {
render((<XTerminal {...makeDefaultProps({ sessionStatus: "active" })} />) as ReactElement);
expect(screen.queryByRole("button", { name: /restart terminal/i })).not.toBeInTheDocument();
});
it("shows exit code in restart button when provided", () => {
render(
(
<XTerminal {...makeDefaultProps({ sessionStatus: "exited", exitCode: 1 })} />
) as ReactElement
);
expect(screen.getByRole("button", { name: /exit 1/i })).toBeInTheDocument();
});
it("calls onRestart when restart button is clicked", () => {
render(
(
<XTerminal {...makeDefaultProps({ sessionStatus: "exited", onRestart: mockOnRestart })} />
) as ReactElement
);
fireEvent.click(screen.getByRole("button", { name: /restart terminal/i }));
expect(mockOnRestart).toHaveBeenCalledTimes(1);
});
});
// ==========================================
// Output callback registration
// ==========================================
describe("registerOutputCallback", () => {
it("registers a callback for its sessionId on mount", () => {
render((<XTerminal {...makeDefaultProps({ sessionId: "test-session" })} />) as ReactElement);
expect(mockRegisterOutputCallback).toHaveBeenCalledWith("test-session", expect.any(Function));
});
it("calls the returned unsubscribe function on unmount", () => {
const unsubscribe = vi.fn();
mockRegisterOutputCallback.mockReturnValue(unsubscribe);
const { unmount } = render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
unmount();
expect(unsubscribe).toHaveBeenCalled();
});
});
// ==========================================
// Accessibility
// ==========================================
describe("accessibility", () => {
it("has an accessible region role", () => {
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument();
});
});
// ==========================================
// Visibility
// ==========================================
describe("isVisible", () => {
it("renders with isVisible=true by default", () => {
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
// Container is present; isVisible affects re-fit timing
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,404 @@
"use client";
/**
* XTerminal component
*
* Renders a real xterm.js terminal. The parent (TerminalPanel via useTerminalSessions)
* owns the WebSocket connection and session lifecycle. This component receives the
* sessionId and control functions as props and registers for output data specific
* to its session.
*
* Handles resize, copy/paste, theme, exit overlay, and reconnect.
*/
import { useEffect, useRef, useCallback } from "react";
import type { ReactElement, CSSProperties } from "react";
import "@xterm/xterm/css/xterm.css";
import type { Terminal as XTerm } from "@xterm/xterm";
import type { FitAddon as XFitAddon } from "@xterm/addon-fit";
import type { SessionStatus } from "@/hooks/useTerminalSessions";
// ==========================================
// Types
// ==========================================
export interface XTerminalProps {
/** Session identifier (provided by useTerminalSessions) */
sessionId: string;
/** Send keyboard input to this session */
sendInput: (sessionId: string, data: string) => void;
/** Notify the server of a terminal resize */
resize: (sessionId: string, cols: number, rows: number) => void;
/** Close this PTY session */
closeSession: (sessionId: string) => void;
/**
* Register a callback to receive output for this session.
* Returns an unsubscribe function.
*/
registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void;
/** Whether the WebSocket is currently connected */
isConnected: boolean;
/** Current PTY process status */
sessionStatus: SessionStatus;
/** Exit code, populated when sessionStatus === 'exited' */
exitCode?: number;
/**
* Called when the user clicks the restart button after the session has exited.
* The parent is responsible for closing the old session and creating a new one.
*/
onRestart?: () => void;
/** Optional CSS class name for the outer container */
className?: string;
/** Optional inline styles for the outer container */
style?: CSSProperties;
/** Whether the terminal is visible (used to trigger re-fit on tab switch) */
isVisible?: boolean;
}
// ==========================================
// Theme helpers
// ==========================================
/**
* Read a CSS variable value from :root via computed styles.
* Falls back to the provided default value if not available (e.g., during SSR).
*/
function getCssVar(varName: string, fallback: string): string {
if (typeof document === "undefined") return fallback;
const value = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
return value.length > 0 ? value : fallback;
}
/**
* Build an xterm.js ITheme object from the current design system CSS variables.
*/
function buildXtermTheme(): Record<string, string> {
return {
background: getCssVar("--bg-deep", "#080b12"),
foreground: getCssVar("--text", "#eef3ff"),
cursor: getCssVar("--success", "#14b8a6"),
cursorAccent: getCssVar("--bg-deep", "#080b12"),
selectionBackground: `${getCssVar("--primary", "#2f80ff")}40`,
selectionForeground: getCssVar("--text", "#eef3ff"),
selectionInactiveBackground: `${getCssVar("--muted", "#8f9db7")}30`,
// Standard ANSI colors mapped to design system
black: getCssVar("--bg-deep", "#080b12"),
red: getCssVar("--danger", "#e5484d"),
green: getCssVar("--success", "#14b8a6"),
yellow: getCssVar("--warn", "#f59e0b"),
blue: getCssVar("--primary", "#2f80ff"),
magenta: getCssVar("--purple", "#8b5cf6"),
cyan: "#06b6d4",
white: getCssVar("--text-2", "#c5d0e6"),
brightBlack: getCssVar("--muted", "#8f9db7"),
brightRed: "#f06a6f",
brightGreen: "#2dd4bf",
brightYellow: "#fbbf24",
brightBlue: "#56a0ff",
brightMagenta: "#a78bfa",
brightCyan: "#22d3ee",
brightWhite: getCssVar("--text", "#eef3ff"),
};
}
// ==========================================
// Component
// ==========================================
/**
* XTerminal renders a real PTY terminal powered by xterm.js.
* The parent provides the sessionId and control functions; this component
* registers for output data and manages the xterm.js instance lifecycle.
*/
export function XTerminal({
sessionId,
sendInput,
resize,
closeSession: _closeSession,
registerOutputCallback,
isConnected,
sessionStatus,
exitCode,
onRestart,
className = "",
style,
isVisible = true,
}: XTerminalProps): ReactElement {
const containerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<XFitAddon | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const isTerminalMountedRef = useRef(false);
const hasExited = sessionStatus === "exited";
// ==========================================
// Fit helper
// ==========================================
const fitAndResize = useCallback((): void => {
const fitAddon = fitAddonRef.current;
const terminal = terminalRef.current;
if (!fitAddon || !terminal) return;
try {
fitAddon.fit();
resize(sessionId, terminal.cols, terminal.rows);
} catch {
// Ignore fit errors (e.g., when container has zero dimensions)
}
}, [resize, sessionId]);
// ==========================================
// Mount xterm.js terminal (client-only)
// ==========================================
useEffect(() => {
if (!containerRef.current || isTerminalMountedRef.current) return;
let cancelled = false;
const mountTerminal = async (): Promise<void> => {
// Dynamic imports ensure DOM-dependent xterm.js modules are never loaded server-side
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-web-links"),
]);
if (cancelled || !containerRef.current) return;
const theme = buildXtermTheme();
const terminal = new Terminal({
fontFamily: "var(--mono, 'Fira Code', 'Cascadia Code', monospace)",
fontSize: 13,
lineHeight: 1.4,
cursorBlink: true,
cursorStyle: "block",
scrollback: 10000,
theme,
allowTransparency: false,
convertEol: true,
// Accessibility
screenReaderMode: false,
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(containerRef.current);
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
isTerminalMountedRef.current = true;
// Initial fit
try {
fitAddon.fit();
} catch {
// Container might not have dimensions yet
}
// Set up ResizeObserver for automatic re-fitting
const observer = new ResizeObserver(() => {
fitAndResize();
});
observer.observe(containerRef.current);
resizeObserverRef.current = observer;
};
void mountTerminal();
return (): void => {
cancelled = true;
};
// Intentionally empty dep array — mount once only
}, []);
// ==========================================
// Register output callback for this session
// ==========================================
useEffect(() => {
const unregister = registerOutputCallback(sessionId, (data: string) => {
terminalRef.current?.write(data);
});
return unregister;
}, [sessionId, registerOutputCallback]);
// ==========================================
// Re-fit when visibility changes
// ==========================================
useEffect(() => {
if (isVisible) {
// Small delay allows CSS transitions to complete before fitting
const id = setTimeout(fitAndResize, 50);
return (): void => {
clearTimeout(id);
};
}
return undefined;
}, [isVisible, fitAndResize]);
// ==========================================
// Wire terminal input → sendInput
// ==========================================
useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) return;
const disposable = terminal.onData((data: string): void => {
sendInput(sessionId, data);
});
return (): void => {
disposable.dispose();
};
}, [sendInput, sessionId]);
// ==========================================
// Update xterm theme when data-theme attribute changes
// ==========================================
useEffect(() => {
const observer = new MutationObserver(() => {
const terminal = terminalRef.current;
if (terminal) {
terminal.options.theme = buildXtermTheme();
}
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return (): void => {
observer.disconnect();
};
}, []);
// ==========================================
// Cleanup on unmount
// ==========================================
useEffect(() => {
return (): void => {
// Cleanup ResizeObserver
resizeObserverRef.current?.disconnect();
resizeObserverRef.current = null;
// Dispose xterm terminal
terminalRef.current?.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
isTerminalMountedRef.current = false;
};
}, []);
// ==========================================
// Restart handler
// ==========================================
const handleRestart = useCallback((): void => {
const terminal = terminalRef.current;
if (terminal) {
terminal.clear();
}
// Notify parent to close old session and create a new one
onRestart?.();
}, [onRestart]);
// ==========================================
// Render
// ==========================================
const containerStyle: CSSProperties = {
flex: 1,
overflow: "hidden",
position: "relative",
backgroundColor: "var(--bg-deep)",
...style,
};
return (
<div
className={className}
style={containerStyle}
role="region"
aria-label="Terminal"
data-testid="xterminal-container"
data-session-id={sessionId}
>
{/* Status bar — show when not connected and not exited */}
{!isConnected && !hasExited && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
padding: "4px 12px",
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: "var(--warn)",
backgroundColor: "var(--bg-deep)",
zIndex: 10,
borderBottom: "1px solid var(--border)",
}}
>
Connecting to terminal...
</div>
)}
{/* Exit overlay */}
{hasExited && (
<div
style={{
position: "absolute",
bottom: 8,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
zIndex: 10,
}}
>
<button
style={{
padding: "4px 12px",
borderRadius: "4px",
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: "var(--text)",
backgroundColor: "var(--surface)",
border: "1px solid var(--border)",
cursor: "pointer",
}}
onClick={handleRestart}
>
Restart terminal{exitCode !== undefined ? ` (exit ${exitCode.toString()})` : ""}
</button>
</div>
)}
{/* xterm.js render target */}
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
padding: "4px 8px",
boxSizing: "border-box",
}}
data-testid="xterm-viewport"
/>
</div>
);
}

View File

@@ -1,2 +1,6 @@
export type { TerminalLine, TerminalTab, TerminalPanelProps } from "./TerminalPanel";
export type { TerminalPanelProps } from "./TerminalPanel";
export { TerminalPanel } from "./TerminalPanel";
export type { XTerminalProps } from "./XTerminal";
export { XTerminal } from "./XTerminal";
export type { AgentTerminalProps } from "./AgentTerminal";
export { AgentTerminal } from "./AgentTerminal";

View File

@@ -37,16 +37,31 @@ export function BaseWidget({
return (
<div
data-widget-id={id}
className={cn(
"flex flex-col h-full bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden",
className
)}
className={cn("flex flex-col h-full overflow-hidden", className)}
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-sm)",
}}
>
{/* Widget Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50">
<div
className="flex items-center justify-between px-4 py-3"
style={{
borderBottom: "1px solid var(--border)",
background: "var(--surface-2)",
}}
>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
{description && <p className="text-xs text-gray-500 truncate mt-0.5">{description}</p>}
<h3 className="text-sm font-semibold truncate" style={{ color: "var(--text)" }}>
{title}
</h3>
{description && (
<p className="text-xs truncate mt-0.5" style={{ color: "var(--muted)" }}>
{description}
</p>
)}
</div>
{/* Control buttons - only show if handlers provided */}
@@ -56,7 +71,8 @@ export function BaseWidget({
<button
onClick={onEdit}
aria-label="Edit widget"
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
className="p-1 rounded transition-colors"
style={{ color: "var(--muted)" }}
title="Edit widget"
>
<Settings className="w-4 h-4" />
@@ -66,7 +82,8 @@ export function BaseWidget({
<button
onClick={onRemove}
aria-label="Remove widget"
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
className="p-1 rounded transition-colors"
style={{ color: "var(--muted)" }}
title="Remove widget"
>
<X className="w-4 h-4" />
@@ -81,15 +98,24 @@ export function BaseWidget({
{isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-2">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-gray-500">Loading...</span>
<div
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
/>
<span className="text-sm" style={{ color: "var(--muted)" }}>
Loading...
</span>
</div>
</div>
) : error ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="text-red-500 text-sm font-medium mb-1">Error</div>
<div className="text-xs text-gray-600">{error}</div>
<div className="text-sm font-medium mb-1" style={{ color: "var(--danger)" }}>
Error
</div>
<div className="text-xs" style={{ color: "var(--muted)" }}>
{error}
</div>
</div>
</div>
) : (

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 */
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
import GridLayout from "react-grid-layout";
import type { Layout, LayoutItem } from "react-grid-layout";
import type { WidgetPlacement } from "@mosaic/shared";
@@ -22,6 +22,7 @@ export interface WidgetGridProps {
layout: WidgetPlacement[];
onLayoutChange: (layout: WidgetPlacement[]) => void;
onRemoveWidget?: (widgetId: string) => void;
onEditWidget?: (widgetId: string) => void;
isEditing?: boolean;
className?: string;
}
@@ -30,9 +31,34 @@ export function WidgetGrid({
layout,
onLayoutChange,
onRemoveWidget,
onEditWidget,
isEditing = false,
className,
}: WidgetGridProps): React.JSX.Element {
// Measure container width for responsive grid
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(1200);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries): void => {
const entry = entries[0];
if (entry) {
setContainerWidth(entry.contentRect.width);
}
});
observer.observe(el);
// Set initial width
setContainerWidth(el.clientWidth);
return (): void => {
observer.disconnect();
};
}, []);
// Convert WidgetPlacement to react-grid-layout Layout format
const gridLayout: Layout = useMemo(
() =>
@@ -96,22 +122,34 @@ export function WidgetGrid({
// Empty state
if (layout.length === 0) {
return (
<div className="flex items-center justify-center h-full min-h-[400px] bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
<div
ref={containerRef}
className="flex items-center justify-center h-full min-h-[400px]"
style={{
background: "var(--surface-2)",
borderRadius: "var(--r-lg)",
border: "2px dashed var(--border)",
}}
>
<div className="text-center">
<p className="text-gray-500 text-lg font-medium">No widgets yet</p>
<p className="text-gray-400 text-sm mt-1">Add widgets to customize your dashboard</p>
<p className="text-lg font-medium" style={{ color: "var(--muted)" }}>
No widgets yet
</p>
<p className="text-sm mt-1" style={{ color: "var(--muted)", opacity: 0.7 }}>
Add widgets to customize your dashboard
</p>
</div>
</div>
);
}
return (
<div className={cn("widget-grid-container", className)}>
<div ref={containerRef} className={cn("widget-grid-container", className)}>
<GridLayout
className="layout"
layout={gridLayout}
onLayoutChange={handleLayoutChange}
width={1200}
width={containerWidth}
gridConfig={{
cols: 12,
rowHeight: 100,
@@ -147,6 +185,12 @@ export function WidgetGrid({
id={item.i}
title={widgetDef.displayName}
description={widgetDef.description}
{...(isEditing &&
onEditWidget && {
onEdit: (): void => {
onEditWidget(item.i);
},
})}
{...(isEditing &&
onRemoveWidget && {
onRemove: (): void => {

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