Compare commits
54 Commits
v0.0.15
...
a7fbc1ccc8
| Author | SHA1 | Date | |
|---|---|---|---|
| a7fbc1ccc8 | |||
| d18cf44546 | |||
| d05b870f08 | |||
| 1aaf5618ce | |||
| 9b2520ce1f | |||
| b110c469c4 | |||
| 859dcfc4b7 | |||
| 13aa52aa53 | |||
| 417c6ab49c | |||
| 8128eb7fbe | |||
| 7de0e734b0 | |||
| 6290fc3d53 | |||
| 9f4de1682f | |||
| 374ca7ace3 | |||
| 72c64d2eeb | |||
| 5f6c520a98 | |||
| 9a7673bea2 | |||
| 91934b9933 | |||
| 7f89682946 | |||
| 8b4c565f20 | |||
| d5ecc0b107 | |||
| a81c4a5edd | |||
| ff5a09c3fb | |||
| f93fa60fff | |||
| cc56f2cbe1 | |||
| f9cccd6965 | |||
| 90c3bbccdf | |||
| 79286e98c6 | |||
| cfd1def4a9 | |||
| f435d8e8c6 | |||
| 3d78b09064 | |||
| a7955b9b32 | |||
| 372cc100cc | |||
| 37cf813b88 | |||
| 3d5b50af11 | |||
| f30c2f790c | |||
| 05b1a93ccb | |||
| a78a8b88e1 | |||
| 172ed1d40f | |||
| ee2ddfc8b8 | |||
| 5a6d00a064 | |||
| ffda74ec12 | |||
| f97be2e6a3 | |||
| 97606713b5 | |||
| d0c720e6da | |||
| 64e817cfb8 | |||
| cd5c2218c8 | |||
| f643d2bc04 | |||
| 8957904ea9 | |||
| 458cac7cdd | |||
| 7581d26567 | |||
| 07f5225a76 | |||
| 7c55464d54 | |||
| ea1620fa7a |
14
.mosaic/orchestrator/mission.json
Normal file
14
.mosaic/orchestrator/mission.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"mission_id": "prd-implementation-20260222",
|
||||||
|
"name": "PRD implementation",
|
||||||
|
"description": "",
|
||||||
|
"project_path": "/home/jwoltje/src/mosaic-stack",
|
||||||
|
"created_at": "2026-02-23T03:20:55Z",
|
||||||
|
"status": "active",
|
||||||
|
"task_prefix": "",
|
||||||
|
"quality_gates": "",
|
||||||
|
"milestone_version": "0.0.1",
|
||||||
|
"milestones": [],
|
||||||
|
"sessions": []
|
||||||
|
}
|
||||||
@@ -18,6 +18,12 @@ COPY turbo.json ./
|
|||||||
# ======================
|
# ======================
|
||||||
FROM base AS deps
|
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 all package.json files for workspace resolution
|
||||||
COPY packages/shared/package.json ./packages/shared/
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
COPY packages/ui/package.json ./packages/ui/
|
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/
|
COPY apps/api/package.json ./apps/api/
|
||||||
|
|
||||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
# 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
|
# 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
|
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)
|
# 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 \
|
&& chmod 755 /usr/local/bin/dumb-init \
|
||||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
"marked-gfm-heading-id": "^4.1.3",
|
"marked-gfm-heading-id": "^4.1.3",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
"matrix-bot-sdk": "^0.8.0",
|
"matrix-bot-sdk": "^0.8.0",
|
||||||
|
"node-pty": "^1.0.0",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"openai": "^6.17.0",
|
"openai": "^6.17.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TerminalSessionStatus" AS ENUM ('ACTIVE', 'CLOSED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "terminal_sessions" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"workspace_id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL DEFAULT 'Terminal',
|
||||||
|
"status" "TerminalSessionStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"closed_at" TIMESTAMPTZ,
|
||||||
|
|
||||||
|
CONSTRAINT "terminal_sessions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "terminal_sessions_workspace_id_idx" ON "terminal_sessions"("workspace_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "terminal_sessions_workspace_id_status_idx" ON "terminal_sessions"("workspace_id", "status");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "terminal_sessions" ADD CONSTRAINT "terminal_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -206,6 +206,11 @@ enum CredentialScope {
|
|||||||
SYSTEM
|
SYSTEM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TerminalSessionStatus {
|
||||||
|
ACTIVE
|
||||||
|
CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// MODELS
|
// MODELS
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -297,6 +302,7 @@ model Workspace {
|
|||||||
federationEventSubscriptions FederationEventSubscription[]
|
federationEventSubscriptions FederationEventSubscription[]
|
||||||
llmUsageLogs LlmUsageLog[]
|
llmUsageLogs LlmUsageLog[]
|
||||||
userCredentials UserCredential[]
|
userCredentials UserCredential[]
|
||||||
|
terminalSessions TerminalSession[]
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@map("workspaces")
|
@@map("workspaces")
|
||||||
@@ -1507,3 +1513,23 @@ model LlmUsageLog {
|
|||||||
@@index([conversationId])
|
@@index([conversationId])
|
||||||
@@map("llm_usage_logs")
|
@@map("llm_usage_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TERMINAL MODULE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model TerminalSession {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
workspaceId String @map("workspace_id") @db.Uuid
|
||||||
|
name String @default("Terminal")
|
||||||
|
status TerminalSessionStatus @default(ACTIVE)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||||
|
closedAt DateTime? @map("closed_at") @db.Timestamptz
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([workspaceId])
|
||||||
|
@@index([workspaceId, status])
|
||||||
|
@@map("terminal_sessions")
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,136 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// WIDGET DEFINITIONS (global, not workspace-scoped)
|
||||||
|
// ============================================
|
||||||
|
const widgetDefs = [
|
||||||
|
{
|
||||||
|
name: "TasksWidget",
|
||||||
|
displayName: "Tasks",
|
||||||
|
description: "View and manage your tasks",
|
||||||
|
component: "TasksWidget",
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 2,
|
||||||
|
minWidth: 1,
|
||||||
|
minHeight: 2,
|
||||||
|
maxWidth: 4,
|
||||||
|
maxHeight: null,
|
||||||
|
configSchema: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CalendarWidget",
|
||||||
|
displayName: "Calendar",
|
||||||
|
description: "View upcoming events and schedule",
|
||||||
|
component: "CalendarWidget",
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 2,
|
||||||
|
minWidth: 2,
|
||||||
|
minHeight: 2,
|
||||||
|
maxWidth: 4,
|
||||||
|
maxHeight: null,
|
||||||
|
configSchema: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "QuickCaptureWidget",
|
||||||
|
displayName: "Quick Capture",
|
||||||
|
description: "Quickly capture notes and tasks",
|
||||||
|
component: "QuickCaptureWidget",
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 1,
|
||||||
|
minWidth: 2,
|
||||||
|
minHeight: 1,
|
||||||
|
maxWidth: 4,
|
||||||
|
maxHeight: 2,
|
||||||
|
configSchema: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AgentStatusWidget",
|
||||||
|
displayName: "Agent Status",
|
||||||
|
description: "Monitor agent activity and status",
|
||||||
|
component: "AgentStatusWidget",
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 2,
|
||||||
|
minWidth: 1,
|
||||||
|
minHeight: 2,
|
||||||
|
maxWidth: 3,
|
||||||
|
maxHeight: null,
|
||||||
|
configSchema: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ActiveProjectsWidget",
|
||||||
|
displayName: "Active Projects & Agent Chains",
|
||||||
|
description: "View active projects and running agent sessions",
|
||||||
|
component: "ActiveProjectsWidget",
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 3,
|
||||||
|
minWidth: 2,
|
||||||
|
minHeight: 2,
|
||||||
|
maxWidth: 4,
|
||||||
|
maxHeight: null,
|
||||||
|
configSchema: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TaskProgressWidget",
|
||||||
|
displayName: "Task Progress",
|
||||||
|
description: "Live progress of orchestrator agent tasks",
|
||||||
|
component: "TaskProgressWidget",
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 2,
|
||||||
|
minWidth: 1,
|
||||||
|
minHeight: 2,
|
||||||
|
maxWidth: 3,
|
||||||
|
maxHeight: null,
|
||||||
|
configSchema: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OrchestratorEventsWidget",
|
||||||
|
displayName: "Orchestrator Events",
|
||||||
|
description: "Recent orchestration events with stream/Matrix visibility",
|
||||||
|
component: "OrchestratorEventsWidget",
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 2,
|
||||||
|
minWidth: 1,
|
||||||
|
minHeight: 2,
|
||||||
|
maxWidth: 4,
|
||||||
|
maxHeight: null,
|
||||||
|
configSchema: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const wd of widgetDefs) {
|
||||||
|
await prisma.widgetDefinition.upsert({
|
||||||
|
where: { name: wd.name },
|
||||||
|
update: {
|
||||||
|
displayName: wd.displayName,
|
||||||
|
description: wd.description,
|
||||||
|
component: wd.component,
|
||||||
|
defaultWidth: wd.defaultWidth,
|
||||||
|
defaultHeight: wd.defaultHeight,
|
||||||
|
minWidth: wd.minWidth,
|
||||||
|
minHeight: wd.minHeight,
|
||||||
|
maxWidth: wd.maxWidth,
|
||||||
|
maxHeight: wd.maxHeight,
|
||||||
|
configSchema: wd.configSchema,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: wd.name,
|
||||||
|
displayName: wd.displayName,
|
||||||
|
description: wd.description,
|
||||||
|
component: wd.component,
|
||||||
|
defaultWidth: wd.defaultWidth,
|
||||||
|
defaultHeight: wd.defaultHeight,
|
||||||
|
minWidth: wd.minWidth,
|
||||||
|
minHeight: wd.minHeight,
|
||||||
|
maxWidth: wd.maxWidth,
|
||||||
|
maxHeight: wd.maxHeight,
|
||||||
|
configSchema: wd.configSchema,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Seeded ${widgetDefs.length} widget definitions`);
|
||||||
|
|
||||||
// Use transaction for atomic seed data reset and creation
|
// Use transaction for atomic seed data reset and creation
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
// Delete existing seed data for idempotency (avoids duplicates on re-run)
|
// Delete existing seed data for idempotency (avoids duplicates on re-run)
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import { FederationModule } from "./federation/federation.module";
|
|||||||
import { CredentialsModule } from "./credentials/credentials.module";
|
import { CredentialsModule } from "./credentials/credentials.module";
|
||||||
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
||||||
import { SpeechModule } from "./speech/speech.module";
|
import { SpeechModule } from "./speech/speech.module";
|
||||||
|
import { DashboardModule } from "./dashboard/dashboard.module";
|
||||||
|
import { TerminalModule } from "./terminal/terminal.module";
|
||||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -101,6 +103,8 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
CredentialsModule,
|
CredentialsModule,
|
||||||
MosaicTelemetryModule,
|
MosaicTelemetryModule,
|
||||||
SpeechModule,
|
SpeechModule,
|
||||||
|
DashboardModule,
|
||||||
|
TerminalModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -254,6 +254,10 @@ export function createAuth(prisma: PrismaClient) {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
plugins: [...getOidcPlugins()],
|
plugins: [...getOidcPlugins()],
|
||||||
|
logger: {
|
||||||
|
disabled: false,
|
||||||
|
level: "error",
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max
|
expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max
|
||||||
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request
|
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request
|
||||||
|
|||||||
@@ -123,6 +123,14 @@ export class AuthController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
|
// BetterAuth writes responses directly — catch silent 500s that bypass NestJS error handling
|
||||||
|
if (res.statusCode >= 500) {
|
||||||
|
this.logger.error(
|
||||||
|
`BetterAuth returned ${String(res.statusCode)} for ${req.method} ${req.url} from ${clientIp}` +
|
||||||
|
` — check container stdout for '# SERVER_ERROR' details`
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const stack = error instanceof Error ? error.stack : undefined;
|
const stack = error instanceof Error ? error.stack : undefined;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface AuthenticatedRequest extends Request {
|
|||||||
user?: AuthenticatedUser;
|
user?: AuthenticatedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller("api/v1/csrf")
|
@Controller("v1/csrf")
|
||||||
export class CsrfController {
|
export class CsrfController {
|
||||||
constructor(private readonly csrfService: CsrfService) {}
|
constructor(private readonly csrfService: CsrfService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -174,17 +174,19 @@ describe("CsrfGuard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Session binding validation", () => {
|
describe("Session binding validation", () => {
|
||||||
it("should reject when user is not authenticated", () => {
|
it("should allow when user context is not yet available (global guard ordering)", () => {
|
||||||
|
// CsrfGuard runs as APP_GUARD before per-controller AuthGuard,
|
||||||
|
// so request.user may not be populated. Double-submit cookie match
|
||||||
|
// is sufficient protection in this case.
|
||||||
const token = generateValidToken("user-123");
|
const token = generateValidToken("user-123");
|
||||||
const context = createContext(
|
const context = createContext(
|
||||||
"POST",
|
"POST",
|
||||||
{ "csrf-token": token },
|
{ "csrf-token": token },
|
||||||
{ "x-csrf-token": token },
|
{ "x-csrf-token": token },
|
||||||
false
|
false
|
||||||
// No userId - unauthenticated
|
// No userId - AuthGuard hasn't run yet
|
||||||
);
|
);
|
||||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
expect(guard.canActivate(context)).toBe(true);
|
||||||
expect(() => guard.canActivate(context)).toThrow("CSRF validation requires authentication");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject token from different session", () => {
|
it("should reject token from different session", () => {
|
||||||
|
|||||||
@@ -89,30 +89,30 @@ export class CsrfGuard implements CanActivate {
|
|||||||
throw new ForbiddenException("CSRF token mismatch");
|
throw new ForbiddenException("CSRF token mismatch");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate session binding via HMAC
|
// Validate session binding via HMAC when user context is available.
|
||||||
|
// CsrfGuard is a global guard (APP_GUARD) that runs before per-controller
|
||||||
|
// AuthGuard, so request.user may not be populated yet. In that case, the
|
||||||
|
// double-submit cookie match above is sufficient CSRF protection.
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
if (!userId) {
|
if (userId) {
|
||||||
this.logger.warn({
|
if (!this.csrfService.validateToken(cookieToken, userId)) {
|
||||||
event: "CSRF_NO_USER_CONTEXT",
|
this.logger.warn({
|
||||||
|
event: "CSRF_SESSION_BINDING_INVALID",
|
||||||
|
method: request.method,
|
||||||
|
path: request.path,
|
||||||
|
securityEvent: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new ForbiddenException("CSRF token not bound to session");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.debug({
|
||||||
|
event: "CSRF_SKIP_SESSION_BINDING",
|
||||||
method: request.method,
|
method: request.method,
|
||||||
path: request.path,
|
path: request.path,
|
||||||
securityEvent: true,
|
reason: "User context not yet available (global guard runs before AuthGuard)",
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new ForbiddenException("CSRF validation requires authentication");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.csrfService.validateToken(cookieToken, userId)) {
|
|
||||||
this.logger.warn({
|
|
||||||
event: "CSRF_SESSION_BINDING_INVALID",
|
|
||||||
method: request.method,
|
|
||||||
path: request.path,
|
|
||||||
securityEvent: true,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
throw new ForbiddenException("CSRF token not bound to session");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
143
apps/api/src/dashboard/dashboard.controller.spec.ts
Normal file
143
apps/api/src/dashboard/dashboard.controller.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { DashboardController } from "./dashboard.controller";
|
||||||
|
import { DashboardService } from "./dashboard.service";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||||
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
|
import type { DashboardSummaryDto } from "./dto";
|
||||||
|
|
||||||
|
describe("DashboardController", () => {
|
||||||
|
let controller: DashboardController;
|
||||||
|
let service: DashboardService;
|
||||||
|
|
||||||
|
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
||||||
|
|
||||||
|
const mockSummary: DashboardSummaryDto = {
|
||||||
|
metrics: {
|
||||||
|
activeAgents: 3,
|
||||||
|
tasksCompleted: 12,
|
||||||
|
totalTasks: 25,
|
||||||
|
tasksInProgress: 5,
|
||||||
|
activeProjects: 4,
|
||||||
|
errorRate: 2.5,
|
||||||
|
},
|
||||||
|
recentActivity: [
|
||||||
|
{
|
||||||
|
id: "550e8400-e29b-41d4-a716-446655440010",
|
||||||
|
action: "CREATED",
|
||||||
|
entityType: "TASK",
|
||||||
|
entityId: "550e8400-e29b-41d4-a716-446655440011",
|
||||||
|
details: { title: "New task" },
|
||||||
|
userId: "550e8400-e29b-41d4-a716-446655440002",
|
||||||
|
createdAt: "2026-02-22T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeJobs: [
|
||||||
|
{
|
||||||
|
id: "550e8400-e29b-41d4-a716-446655440020",
|
||||||
|
type: "code-task",
|
||||||
|
status: "RUNNING",
|
||||||
|
progressPercent: 45,
|
||||||
|
createdAt: "2026-02-22T11:00:00.000Z",
|
||||||
|
updatedAt: "2026-02-22T11:30:00.000Z",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: "550e8400-e29b-41d4-a716-446655440030",
|
||||||
|
name: "Setup",
|
||||||
|
status: "COMPLETED",
|
||||||
|
phase: "SETUP",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokenBudget: [
|
||||||
|
{
|
||||||
|
model: "agent-1",
|
||||||
|
used: 5000,
|
||||||
|
limit: 10000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockDashboardService = {
|
||||||
|
getSummary: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAuthGuard = {
|
||||||
|
canActivate: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkspaceGuard = {
|
||||||
|
canActivate: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPermissionGuard = {
|
||||||
|
canActivate: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [DashboardController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: DashboardService,
|
||||||
|
useValue: mockDashboardService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue(mockAuthGuard)
|
||||||
|
.overrideGuard(WorkspaceGuard)
|
||||||
|
.useValue(mockWorkspaceGuard)
|
||||||
|
.overrideGuard(PermissionGuard)
|
||||||
|
.useValue(mockPermissionGuard)
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<DashboardController>(DashboardController);
|
||||||
|
service = module.get<DashboardService>(DashboardService);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSummary", () => {
|
||||||
|
it("should return dashboard summary for workspace", async () => {
|
||||||
|
mockDashboardService.getSummary.mockResolvedValue(mockSummary);
|
||||||
|
|
||||||
|
const result = await controller.getSummary(mockWorkspaceId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSummary);
|
||||||
|
expect(service.getSummary).toHaveBeenCalledWith(mockWorkspaceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty arrays when no data exists", async () => {
|
||||||
|
const emptySummary: DashboardSummaryDto = {
|
||||||
|
metrics: {
|
||||||
|
activeAgents: 0,
|
||||||
|
tasksCompleted: 0,
|
||||||
|
totalTasks: 0,
|
||||||
|
tasksInProgress: 0,
|
||||||
|
activeProjects: 0,
|
||||||
|
errorRate: 0,
|
||||||
|
},
|
||||||
|
recentActivity: [],
|
||||||
|
activeJobs: [],
|
||||||
|
tokenBudget: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDashboardService.getSummary.mockResolvedValue(emptySummary);
|
||||||
|
|
||||||
|
const result = await controller.getSummary(mockWorkspaceId);
|
||||||
|
|
||||||
|
expect(result).toEqual(emptySummary);
|
||||||
|
expect(result.metrics.errorRate).toBe(0);
|
||||||
|
expect(result.recentActivity).toHaveLength(0);
|
||||||
|
expect(result.activeJobs).toHaveLength(0);
|
||||||
|
expect(result.tokenBudget).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
35
apps/api/src/dashboard/dashboard.controller.ts
Normal file
35
apps/api/src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Controller, Get, UseGuards, BadRequestException } from "@nestjs/common";
|
||||||
|
import { DashboardService } from "./dashboard.service";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
|
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||||
|
import type { DashboardSummaryDto } from "./dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for dashboard endpoints.
|
||||||
|
* Returns aggregated summary data for the workspace dashboard.
|
||||||
|
*
|
||||||
|
* Guards are applied in order:
|
||||||
|
* 1. AuthGuard - Verifies user authentication
|
||||||
|
* 2. WorkspaceGuard - Validates workspace access and sets RLS context
|
||||||
|
* 3. PermissionGuard - Checks role-based permissions
|
||||||
|
*/
|
||||||
|
@Controller("dashboard")
|
||||||
|
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||||
|
export class DashboardController {
|
||||||
|
constructor(private readonly dashboardService: DashboardService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/dashboard/summary
|
||||||
|
* Returns aggregated metrics, recent activity, active jobs, and token budgets
|
||||||
|
* Requires: Any workspace member (including GUEST)
|
||||||
|
*/
|
||||||
|
@Get("summary")
|
||||||
|
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||||
|
async getSummary(@Workspace() workspaceId: string | undefined): Promise<DashboardSummaryDto> {
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new BadRequestException("Workspace context required");
|
||||||
|
}
|
||||||
|
return this.dashboardService.getSummary(workspaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/dashboard/dashboard.module.ts
Normal file
13
apps/api/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { DashboardController } from "./dashboard.controller";
|
||||||
|
import { DashboardService } from "./dashboard.service";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, AuthModule],
|
||||||
|
controllers: [DashboardController],
|
||||||
|
providers: [DashboardService],
|
||||||
|
exports: [DashboardService],
|
||||||
|
})
|
||||||
|
export class DashboardModule {}
|
||||||
187
apps/api/src/dashboard/dashboard.service.ts
Normal file
187
apps/api/src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { AgentStatus, ProjectStatus, RunnerJobStatus, TaskStatus } from "@prisma/client";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type {
|
||||||
|
DashboardSummaryDto,
|
||||||
|
ActiveJobDto,
|
||||||
|
RecentActivityDto,
|
||||||
|
TokenBudgetEntryDto,
|
||||||
|
} from "./dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for aggregating dashboard summary data.
|
||||||
|
* Executes all queries in parallel to minimize latency.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get aggregated dashboard summary for a workspace
|
||||||
|
*/
|
||||||
|
async getSummary(workspaceId: string): Promise<DashboardSummaryDto> {
|
||||||
|
const now = new Date();
|
||||||
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Execute all queries in parallel
|
||||||
|
const [
|
||||||
|
activeAgents,
|
||||||
|
tasksCompleted,
|
||||||
|
totalTasks,
|
||||||
|
tasksInProgress,
|
||||||
|
activeProjects,
|
||||||
|
failedJobsLast24h,
|
||||||
|
totalJobsLast24h,
|
||||||
|
recentActivityRows,
|
||||||
|
activeJobRows,
|
||||||
|
tokenBudgetRows,
|
||||||
|
] = await Promise.all([
|
||||||
|
// Active agents: IDLE, WORKING, WAITING
|
||||||
|
this.prisma.agent.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: { in: [AgentStatus.IDLE, AgentStatus.WORKING, AgentStatus.WAITING] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Tasks completed
|
||||||
|
this.prisma.task.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: TaskStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Total tasks
|
||||||
|
this.prisma.task.count({
|
||||||
|
where: { workspaceId },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Tasks in progress
|
||||||
|
this.prisma.task.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: TaskStatus.IN_PROGRESS,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Active projects
|
||||||
|
this.prisma.project.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: ProjectStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Failed jobs in last 24h (for error rate)
|
||||||
|
this.prisma.runnerJob.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: RunnerJobStatus.FAILED,
|
||||||
|
createdAt: { gte: oneDayAgo },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Total jobs in last 24h (for error rate)
|
||||||
|
this.prisma.runnerJob.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
createdAt: { gte: oneDayAgo },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Recent activity: last 10 entries
|
||||||
|
this.prisma.activityLog.findMany({
|
||||||
|
where: { workspaceId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Active jobs: PENDING, QUEUED, RUNNING with steps
|
||||||
|
this.prisma.runnerJob.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: {
|
||||||
|
in: [RunnerJobStatus.PENDING, RunnerJobStatus.QUEUED, RunnerJobStatus.RUNNING],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
steps: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
phase: true,
|
||||||
|
},
|
||||||
|
orderBy: { ordinal: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Token budgets for workspace (active, not yet completed)
|
||||||
|
this.prisma.tokenBudget.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
agentId: true,
|
||||||
|
totalTokensUsed: true,
|
||||||
|
allocatedTokens: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Compute error rate
|
||||||
|
const errorRate = totalJobsLast24h > 0 ? (failedJobsLast24h / totalJobsLast24h) * 100 : 0;
|
||||||
|
|
||||||
|
// Map recent activity
|
||||||
|
const recentActivity: RecentActivityDto[] = recentActivityRows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
action: row.action,
|
||||||
|
entityType: row.entityType,
|
||||||
|
entityId: row.entityId,
|
||||||
|
details: row.details as Record<string, unknown> | null,
|
||||||
|
userId: row.userId,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Map active jobs (RunnerJob lacks updatedAt; use startedAt or createdAt as proxy)
|
||||||
|
const activeJobs: ActiveJobDto[] = activeJobRows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
type: row.type,
|
||||||
|
status: row.status,
|
||||||
|
progressPercent: row.progressPercent,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: (row.startedAt ?? row.createdAt).toISOString(),
|
||||||
|
steps: row.steps.map((step) => ({
|
||||||
|
id: step.id,
|
||||||
|
name: step.name,
|
||||||
|
status: step.status,
|
||||||
|
phase: step.phase,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Map token budget entries
|
||||||
|
const tokenBudget: TokenBudgetEntryDto[] = tokenBudgetRows.map((row) => ({
|
||||||
|
model: row.agentId,
|
||||||
|
used: row.totalTokensUsed,
|
||||||
|
limit: row.allocatedTokens,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
metrics: {
|
||||||
|
activeAgents,
|
||||||
|
tasksCompleted,
|
||||||
|
totalTasks,
|
||||||
|
tasksInProgress,
|
||||||
|
activeProjects,
|
||||||
|
errorRate: Math.round(errorRate * 100) / 100,
|
||||||
|
},
|
||||||
|
recentActivity,
|
||||||
|
activeJobs,
|
||||||
|
tokenBudget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/api/src/dashboard/dto/dashboard-summary.dto.ts
Normal file
53
apps/api/src/dashboard/dto/dashboard-summary.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard Summary DTO
|
||||||
|
* Defines the response shape for the dashboard summary endpoint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class DashboardMetricsDto {
|
||||||
|
activeAgents!: number;
|
||||||
|
tasksCompleted!: number;
|
||||||
|
totalTasks!: number;
|
||||||
|
tasksInProgress!: number;
|
||||||
|
activeProjects!: number;
|
||||||
|
errorRate!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecentActivityDto {
|
||||||
|
id!: string;
|
||||||
|
action!: string;
|
||||||
|
entityType!: string;
|
||||||
|
entityId!: string;
|
||||||
|
details!: Record<string, unknown> | null;
|
||||||
|
userId!: string;
|
||||||
|
createdAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActiveJobStepDto {
|
||||||
|
id!: string;
|
||||||
|
name!: string;
|
||||||
|
status!: string;
|
||||||
|
phase!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActiveJobDto {
|
||||||
|
id!: string;
|
||||||
|
type!: string;
|
||||||
|
status!: string;
|
||||||
|
progressPercent!: number;
|
||||||
|
createdAt!: string;
|
||||||
|
updatedAt!: string;
|
||||||
|
steps!: ActiveJobStepDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TokenBudgetEntryDto {
|
||||||
|
model!: string;
|
||||||
|
used!: number;
|
||||||
|
limit!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DashboardSummaryDto {
|
||||||
|
metrics!: DashboardMetricsDto;
|
||||||
|
recentActivity!: RecentActivityDto[];
|
||||||
|
activeJobs!: ActiveJobDto[];
|
||||||
|
tokenBudget!: TokenBudgetEntryDto[];
|
||||||
|
}
|
||||||
1
apps/api/src/dashboard/dto/index.ts
Normal file
1
apps/api/src/dashboard/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./dashboard-summary.dto";
|
||||||
@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
|
|||||||
import type { CommandMessageDetails, CommandResponse } from "./types/message.types";
|
import type { CommandMessageDetails, CommandResponse } from "./types/message.types";
|
||||||
import type { FederationMessageStatus } from "@prisma/client";
|
import type { FederationMessageStatus } from "@prisma/client";
|
||||||
|
|
||||||
@Controller("api/v1/federation")
|
@Controller("v1/federation")
|
||||||
export class CommandController {
|
export class CommandController {
|
||||||
private readonly logger = new Logger(CommandController.name);
|
private readonly logger = new Logger(CommandController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
IncomingEventAckDto,
|
IncomingEventAckDto,
|
||||||
} from "./dto/event.dto";
|
} from "./dto/event.dto";
|
||||||
|
|
||||||
@Controller("api/v1/federation")
|
@Controller("v1/federation")
|
||||||
export class EventController {
|
export class EventController {
|
||||||
private readonly logger = new Logger(EventController.name);
|
private readonly logger = new Logger(EventController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
ValidateFederatedTokenDto,
|
ValidateFederatedTokenDto,
|
||||||
} from "./dto/federated-auth.dto";
|
} from "./dto/federated-auth.dto";
|
||||||
|
|
||||||
@Controller("api/v1/federation/auth")
|
@Controller("v1/federation/auth")
|
||||||
export class FederationAuthController {
|
export class FederationAuthController {
|
||||||
private readonly logger = new Logger(FederationAuthController.name);
|
private readonly logger = new Logger(FederationAuthController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
} from "./dto/connection.dto";
|
} from "./dto/connection.dto";
|
||||||
import { FederationConnectionStatus } from "@prisma/client";
|
import { FederationConnectionStatus } from "@prisma/client";
|
||||||
|
|
||||||
@Controller("api/v1/federation")
|
@Controller("v1/federation")
|
||||||
export class FederationController {
|
export class FederationController {
|
||||||
private readonly logger = new Logger(FederationController.name);
|
private readonly logger = new Logger(FederationController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
|
|||||||
import type { QueryMessageDetails, QueryResponse } from "./types/message.types";
|
import type { QueryMessageDetails, QueryResponse } from "./types/message.types";
|
||||||
import type { FederationMessageStatus } from "@prisma/client";
|
import type { FederationMessageStatus } from "@prisma/client";
|
||||||
|
|
||||||
@Controller("api/v1/federation")
|
@Controller("v1/federation")
|
||||||
export class QueryController {
|
export class QueryController {
|
||||||
private readonly logger = new Logger(QueryController.name);
|
private readonly logger = new Logger(QueryController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { ValidationPipe } from "@nestjs/common";
|
import { RequestMethod, ValidationPipe } from "@nestjs/common";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { getTrustedOrigins } from "./auth/auth.config";
|
import { getTrustedOrigins } from "./auth/auth.config";
|
||||||
@@ -47,6 +47,16 @@ async function bootstrap() {
|
|||||||
|
|
||||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||||
|
|
||||||
|
// Set global API prefix — all routes get /api/* except auth and health
|
||||||
|
// Auth routes are excluded because BetterAuth expects /auth/* paths
|
||||||
|
// Health is excluded because Docker healthchecks hit /health directly
|
||||||
|
app.setGlobalPrefix("api", {
|
||||||
|
exclude: [
|
||||||
|
{ path: "health", method: RequestMethod.GET },
|
||||||
|
{ path: "auth/(.*)", method: RequestMethod.ALL },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// Configure CORS for cookie-based authentication
|
// Configure CORS for cookie-based authentication
|
||||||
// Origin list is shared with BetterAuth trustedOrigins via getTrustedOrigins()
|
// Origin list is shared with BetterAuth trustedOrigins via getTrustedOrigins()
|
||||||
const trustedOrigins = getTrustedOrigins();
|
const trustedOrigins = getTrustedOrigins();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RunnerJobsService } from "./runner-jobs.service";
|
|||||||
import { PrismaModule } from "../prisma/prisma.module";
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
import { BullMqModule } from "../bullmq/bullmq.module";
|
import { BullMqModule } from "../bullmq/bullmq.module";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { WebSocketModule } from "../websocket/websocket.module";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runner Jobs Module
|
* Runner Jobs Module
|
||||||
@@ -12,7 +13,7 @@ import { AuthModule } from "../auth/auth.module";
|
|||||||
* for asynchronous job processing.
|
* for asynchronous job processing.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, BullMqModule, AuthModule],
|
imports: [PrismaModule, BullMqModule, AuthModule, WebSocketModule],
|
||||||
controllers: [RunnerJobsController],
|
controllers: [RunnerJobsController],
|
||||||
providers: [RunnerJobsService],
|
providers: [RunnerJobsService],
|
||||||
exports: [RunnerJobsService],
|
exports: [RunnerJobsService],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { RunnerJobsService } from "./runner-jobs.service";
|
import { RunnerJobsService } from "./runner-jobs.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { BullMqService } from "../bullmq/bullmq.service";
|
import { BullMqService } from "../bullmq/bullmq.service";
|
||||||
|
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||||
import { RunnerJobStatus } from "@prisma/client";
|
import { RunnerJobStatus } from "@prisma/client";
|
||||||
import { ConflictException, BadRequestException } from "@nestjs/common";
|
import { ConflictException, BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
@@ -19,6 +20,12 @@ describe("RunnerJobsService - Concurrency", () => {
|
|||||||
getQueue: vi.fn(),
|
getQueue: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockWebSocketGateway = {
|
||||||
|
emitJobCreated: vi.fn(),
|
||||||
|
emitJobStatusChanged: vi.fn(),
|
||||||
|
emitJobProgress: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -37,6 +44,10 @@ describe("RunnerJobsService - Concurrency", () => {
|
|||||||
provide: BullMqService,
|
provide: BullMqService,
|
||||||
useValue: mockBullMqService,
|
useValue: mockBullMqService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: WebSocketGateway,
|
||||||
|
useValue: mockWebSocketGateway,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { RunnerJobsService } from "./runner-jobs.service";
|
import { RunnerJobsService } from "./runner-jobs.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { BullMqService } from "../bullmq/bullmq.service";
|
import { BullMqService } from "../bullmq/bullmq.service";
|
||||||
|
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||||
import { RunnerJobStatus } from "@prisma/client";
|
import { RunnerJobStatus } from "@prisma/client";
|
||||||
import { NotFoundException, BadRequestException } from "@nestjs/common";
|
import { NotFoundException, BadRequestException } from "@nestjs/common";
|
||||||
import { CreateJobDto, QueryJobsDto } from "./dto";
|
import { CreateJobDto, QueryJobsDto } from "./dto";
|
||||||
@@ -32,6 +33,12 @@ describe("RunnerJobsService", () => {
|
|||||||
getQueue: vi.fn(),
|
getQueue: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockWebSocketGateway = {
|
||||||
|
emitJobCreated: vi.fn(),
|
||||||
|
emitJobStatusChanged: vi.fn(),
|
||||||
|
emitJobProgress: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -44,6 +51,10 @@ describe("RunnerJobsService", () => {
|
|||||||
provide: BullMqService,
|
provide: BullMqService,
|
||||||
useValue: mockBullMqService,
|
useValue: mockBullMqService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: WebSocketGateway,
|
||||||
|
useValue: mockWebSocketGateway,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Prisma, RunnerJobStatus } from "@prisma/client";
|
|||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { BullMqService } from "../bullmq/bullmq.service";
|
import { BullMqService } from "../bullmq/bullmq.service";
|
||||||
|
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||||
import { QUEUE_NAMES } from "../bullmq/queues";
|
import { QUEUE_NAMES } from "../bullmq/queues";
|
||||||
import { ConcurrentUpdateException } from "../common/exceptions/concurrent-update.exception";
|
import { ConcurrentUpdateException } from "../common/exceptions/concurrent-update.exception";
|
||||||
import type { CreateJobDto, QueryJobsDto } from "./dto";
|
import type { CreateJobDto, QueryJobsDto } from "./dto";
|
||||||
@@ -14,7 +15,8 @@ import type { CreateJobDto, QueryJobsDto } from "./dto";
|
|||||||
export class RunnerJobsService {
|
export class RunnerJobsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly bullMq: BullMqService
|
private readonly bullMq: BullMqService,
|
||||||
|
private readonly wsGateway: WebSocketGateway
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +58,8 @@ export class RunnerJobsService {
|
|||||||
{ priority }
|
{ priority }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.wsGateway.emitJobCreated(workspaceId, job);
|
||||||
|
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +198,13 @@ export class RunnerJobsService {
|
|||||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after cancel`);
|
throw new NotFoundException(`RunnerJob with ID ${id} not found after cancel`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
status: job.status,
|
||||||
|
previousStatus: existingJob.status,
|
||||||
|
});
|
||||||
|
|
||||||
return job;
|
return job;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -248,6 +259,8 @@ export class RunnerJobsService {
|
|||||||
{ priority: existingJob.priority }
|
{ priority: existingJob.priority }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.wsGateway.emitJobCreated(workspaceId, newJob);
|
||||||
|
|
||||||
return newJob;
|
return newJob;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,6 +543,13 @@ export class RunnerJobsService {
|
|||||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
status: updatedJob.status,
|
||||||
|
previousStatus: existingJob.status,
|
||||||
|
});
|
||||||
|
|
||||||
return updatedJob;
|
return updatedJob;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -606,6 +626,12 @@ export class RunnerJobsService {
|
|||||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.wsGateway.emitJobProgress(workspaceId, id, {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
progressPercent: updatedJob.progressPercent,
|
||||||
|
});
|
||||||
|
|
||||||
return updatedJob;
|
return updatedJob;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
53
apps/api/src/terminal/terminal-session.dto.ts
Normal file
53
apps/api/src/terminal/terminal-session.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Terminal Session DTOs
|
||||||
|
*
|
||||||
|
* Data Transfer Objects for terminal session persistence endpoints.
|
||||||
|
* Validated using class-validator decorators.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IsString, IsOptional, MaxLength, IsEnum, IsUUID } from "class-validator";
|
||||||
|
import { TerminalSessionStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a new terminal session record.
|
||||||
|
*/
|
||||||
|
export class CreateTerminalSessionDto {
|
||||||
|
@IsString()
|
||||||
|
@IsUUID()
|
||||||
|
workspaceId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for querying terminal sessions by workspace.
|
||||||
|
*/
|
||||||
|
export class FindTerminalSessionsByWorkspaceDto {
|
||||||
|
@IsString()
|
||||||
|
@IsUUID()
|
||||||
|
workspaceId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response shape for a terminal session.
|
||||||
|
*/
|
||||||
|
export class TerminalSessionResponseDto {
|
||||||
|
id!: string;
|
||||||
|
workspaceId!: string;
|
||||||
|
name!: string;
|
||||||
|
status!: TerminalSessionStatus;
|
||||||
|
createdAt!: Date;
|
||||||
|
closedAt!: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for filtering terminal sessions by status.
|
||||||
|
*/
|
||||||
|
export class TerminalSessionStatusFilterDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TerminalSessionStatus)
|
||||||
|
status?: TerminalSessionStatus;
|
||||||
|
}
|
||||||
229
apps/api/src/terminal/terminal-session.service.spec.ts
Normal file
229
apps/api/src/terminal/terminal-session.service.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* TerminalSessionService Tests
|
||||||
|
*
|
||||||
|
* Unit tests for database-backed terminal session CRUD:
|
||||||
|
* create, findByWorkspace, close, and findById.
|
||||||
|
* PrismaService is mocked to isolate the service logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { TerminalSessionStatus } from "@prisma/client";
|
||||||
|
import type { TerminalSession } from "@prisma/client";
|
||||||
|
import { TerminalSessionService } from "./terminal-session.service";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function makeSession(overrides: Partial<TerminalSession> = {}): TerminalSession {
|
||||||
|
return {
|
||||||
|
id: "session-uuid-1",
|
||||||
|
workspaceId: "workspace-uuid-1",
|
||||||
|
name: "Terminal",
|
||||||
|
status: TerminalSessionStatus.ACTIVE,
|
||||||
|
createdAt: new Date("2026-02-25T00:00:00Z"),
|
||||||
|
closedAt: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mock PrismaService
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function makeMockPrisma() {
|
||||||
|
return {
|
||||||
|
terminalSession: {
|
||||||
|
create: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tests
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("TerminalSessionService", () => {
|
||||||
|
let service: TerminalSessionService;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let mockPrisma: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockPrisma = makeMockPrisma();
|
||||||
|
service = new TerminalSessionService(mockPrisma);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// create
|
||||||
|
// ==========================================
|
||||||
|
describe("create", () => {
|
||||||
|
it("should call prisma.terminalSession.create with workspaceId only when no name provided", async () => {
|
||||||
|
const session = makeSession();
|
||||||
|
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||||
|
|
||||||
|
const result = await service.create("workspace-uuid-1");
|
||||||
|
|
||||||
|
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
|
||||||
|
data: { workspaceId: "workspace-uuid-1" },
|
||||||
|
});
|
||||||
|
expect(result).toEqual(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include name in create data when name is provided", async () => {
|
||||||
|
const session = makeSession({ name: "My Terminal" });
|
||||||
|
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||||
|
|
||||||
|
const result = await service.create("workspace-uuid-1", "My Terminal");
|
||||||
|
|
||||||
|
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
|
||||||
|
data: { workspaceId: "workspace-uuid-1", name: "My Terminal" },
|
||||||
|
});
|
||||||
|
expect(result).toEqual(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the created session", async () => {
|
||||||
|
const session = makeSession();
|
||||||
|
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||||
|
|
||||||
|
const result = await service.create("workspace-uuid-1");
|
||||||
|
|
||||||
|
expect(result.id).toBe("session-uuid-1");
|
||||||
|
expect(result.status).toBe(TerminalSessionStatus.ACTIVE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// findByWorkspace
|
||||||
|
// ==========================================
|
||||||
|
describe("findByWorkspace", () => {
|
||||||
|
it("should query for ACTIVE sessions in the given workspace, ordered by createdAt desc", async () => {
|
||||||
|
const sessions = [makeSession(), makeSession({ id: "session-uuid-2" })];
|
||||||
|
mockPrisma.terminalSession.findMany.mockResolvedValueOnce(sessions);
|
||||||
|
|
||||||
|
const result = await service.findByWorkspace("workspace-uuid-1");
|
||||||
|
|
||||||
|
expect(mockPrisma.terminalSession.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
workspaceId: "workspace-uuid-1",
|
||||||
|
status: TerminalSessionStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return an empty array when no active sessions exist", async () => {
|
||||||
|
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const result = await service.findByWorkspace("workspace-uuid-empty");
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not include CLOSED sessions", async () => {
|
||||||
|
// The where clause enforces ACTIVE status — verify it is present
|
||||||
|
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await service.findByWorkspace("workspace-uuid-1");
|
||||||
|
|
||||||
|
const callArgs = mockPrisma.terminalSession.findMany.mock.calls[0][0] as {
|
||||||
|
where: { status: TerminalSessionStatus };
|
||||||
|
};
|
||||||
|
expect(callArgs.where.status).toBe(TerminalSessionStatus.ACTIVE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// close
|
||||||
|
// ==========================================
|
||||||
|
describe("close", () => {
|
||||||
|
it("should set status to CLOSED and set closedAt when session exists", async () => {
|
||||||
|
const existingSession = makeSession();
|
||||||
|
const closedSession = makeSession({
|
||||||
|
status: TerminalSessionStatus.CLOSED,
|
||||||
|
closedAt: new Date("2026-02-25T01:00:00Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
|
||||||
|
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
|
||||||
|
|
||||||
|
const result = await service.close("session-uuid-1");
|
||||||
|
|
||||||
|
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: "session-uuid-1" },
|
||||||
|
});
|
||||||
|
expect(mockPrisma.terminalSession.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: "session-uuid-1" },
|
||||||
|
data: {
|
||||||
|
status: TerminalSessionStatus.CLOSED,
|
||||||
|
closedAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(TerminalSessionStatus.CLOSED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when session does not exist", async () => {
|
||||||
|
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
await expect(service.close("nonexistent-id")).rejects.toThrow(NotFoundException);
|
||||||
|
expect(mockPrisma.terminalSession.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include a non-null closedAt timestamp on close", async () => {
|
||||||
|
const existingSession = makeSession();
|
||||||
|
const closedSession = makeSession({
|
||||||
|
status: TerminalSessionStatus.CLOSED,
|
||||||
|
closedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
|
||||||
|
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
|
||||||
|
|
||||||
|
const result = await service.close("session-uuid-1");
|
||||||
|
|
||||||
|
expect(result.closedAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// findById
|
||||||
|
// ==========================================
|
||||||
|
describe("findById", () => {
|
||||||
|
it("should return the session when it exists", async () => {
|
||||||
|
const session = makeSession();
|
||||||
|
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(session);
|
||||||
|
|
||||||
|
const result = await service.findById("session-uuid-1");
|
||||||
|
|
||||||
|
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: "session-uuid-1" },
|
||||||
|
});
|
||||||
|
expect(result).toEqual(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when session does not exist", async () => {
|
||||||
|
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const result = await service.findById("no-such-id");
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find CLOSED sessions as well as ACTIVE ones", async () => {
|
||||||
|
const closedSession = makeSession({
|
||||||
|
status: TerminalSessionStatus.CLOSED,
|
||||||
|
closedAt: new Date(),
|
||||||
|
});
|
||||||
|
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(closedSession);
|
||||||
|
|
||||||
|
const result = await service.findById("session-uuid-1");
|
||||||
|
|
||||||
|
expect(result?.status).toBe(TerminalSessionStatus.CLOSED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
96
apps/api/src/terminal/terminal-session.service.ts
Normal file
96
apps/api/src/terminal/terminal-session.service.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* TerminalSessionService
|
||||||
|
*
|
||||||
|
* Manages database persistence for terminal sessions.
|
||||||
|
* Provides CRUD operations on the TerminalSession model,
|
||||||
|
* enabling session tracking, recovery, and workspace-level listing.
|
||||||
|
*
|
||||||
|
* Session lifecycle:
|
||||||
|
* - create: record a new terminal session with ACTIVE status
|
||||||
|
* - findByWorkspace: return all ACTIVE sessions for a workspace
|
||||||
|
* - close: mark a session as CLOSED, set closedAt timestamp
|
||||||
|
* - findById: retrieve a single session by ID
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
|
||||||
|
import { TerminalSessionStatus } from "@prisma/client";
|
||||||
|
import type { TerminalSession } from "@prisma/client";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TerminalSessionService {
|
||||||
|
private readonly logger = new Logger(TerminalSessionService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new terminal session record in the database.
|
||||||
|
*
|
||||||
|
* @param workspaceId - The workspace this session belongs to
|
||||||
|
* @param name - Optional display name for the session (defaults to "Terminal")
|
||||||
|
* @returns The created TerminalSession record
|
||||||
|
*/
|
||||||
|
async create(workspaceId: string, name?: string): Promise<TerminalSession> {
|
||||||
|
this.logger.log(
|
||||||
|
`Creating terminal session for workspace ${workspaceId}${name !== undefined ? ` (name: ${name})` : ""}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const data: { workspaceId: string; name?: string } = { workspaceId };
|
||||||
|
if (name !== undefined) {
|
||||||
|
data.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.terminalSession.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all ACTIVE terminal sessions for a workspace.
|
||||||
|
*
|
||||||
|
* @param workspaceId - The workspace to query
|
||||||
|
* @returns Array of active TerminalSession records, ordered by creation time (newest first)
|
||||||
|
*/
|
||||||
|
async findByWorkspace(workspaceId: string): Promise<TerminalSession[]> {
|
||||||
|
return this.prisma.terminalSession.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
status: TerminalSessionStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close a terminal session by setting its status to CLOSED and recording closedAt.
|
||||||
|
*
|
||||||
|
* @param id - The session ID to close
|
||||||
|
* @returns The updated TerminalSession record
|
||||||
|
* @throws NotFoundException if the session does not exist
|
||||||
|
*/
|
||||||
|
async close(id: string): Promise<TerminalSession> {
|
||||||
|
const existing = await this.prisma.terminalSession.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException(`Terminal session ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Closing terminal session ${id} (workspace: ${existing.workspaceId})`);
|
||||||
|
|
||||||
|
return this.prisma.terminalSession.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: TerminalSessionStatus.CLOSED,
|
||||||
|
closedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a terminal session by ID.
|
||||||
|
*
|
||||||
|
* @param id - The session ID to retrieve
|
||||||
|
* @returns The TerminalSession record, or null if not found
|
||||||
|
*/
|
||||||
|
async findById(id: string): Promise<TerminalSession | null> {
|
||||||
|
return this.prisma.terminalSession.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
89
apps/api/src/terminal/terminal.dto.ts
Normal file
89
apps/api/src/terminal/terminal.dto.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Terminal DTOs
|
||||||
|
*
|
||||||
|
* Data Transfer Objects for terminal WebSocket events.
|
||||||
|
* Validated using class-validator decorators.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsNumber,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a new terminal PTY session.
|
||||||
|
*/
|
||||||
|
export class CreateTerminalDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(500)
|
||||||
|
cols?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
|
rows?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4096)
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for sending input data to a terminal PTY session.
|
||||||
|
*/
|
||||||
|
export class TerminalInputDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(64)
|
||||||
|
sessionId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
data!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for resizing a terminal PTY session.
|
||||||
|
*/
|
||||||
|
export class TerminalResizeDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(64)
|
||||||
|
sessionId!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(500)
|
||||||
|
cols!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
|
rows!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for closing a terminal PTY session.
|
||||||
|
*/
|
||||||
|
export class CloseTerminalDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(64)
|
||||||
|
sessionId!: string;
|
||||||
|
}
|
||||||
501
apps/api/src/terminal/terminal.gateway.spec.ts
Normal file
501
apps/api/src/terminal/terminal.gateway.spec.ts
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
/**
|
||||||
|
* TerminalGateway Tests
|
||||||
|
*
|
||||||
|
* Unit tests for WebSocket terminal gateway:
|
||||||
|
* - Authentication on connection
|
||||||
|
* - terminal:create event handling
|
||||||
|
* - terminal:input event handling
|
||||||
|
* - terminal:resize event handling
|
||||||
|
* - terminal:close event handling
|
||||||
|
* - disconnect cleanup
|
||||||
|
* - Error paths
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||||
|
import type { Socket } from "socket.io";
|
||||||
|
import { TerminalGateway } from "./terminal.gateway";
|
||||||
|
import { TerminalService } from "./terminal.service";
|
||||||
|
import { AuthService } from "../auth/auth.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mocks
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Mock node-pty globally so TerminalService doesn't fail to import
|
||||||
|
vi.mock("node-pty", () => ({
|
||||||
|
spawn: vi.fn(() => ({
|
||||||
|
onData: vi.fn(),
|
||||||
|
onExit: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
resize: vi.fn(),
|
||||||
|
kill: vi.fn(),
|
||||||
|
pid: 1000,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface AuthenticatedSocket extends Socket {
|
||||||
|
data: {
|
||||||
|
userId?: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockSocket(id = "test-socket-id"): AuthenticatedSocket {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
emit: vi.fn(),
|
||||||
|
join: vi.fn(),
|
||||||
|
leave: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
data: {},
|
||||||
|
handshake: {
|
||||||
|
auth: { token: "valid-token" },
|
||||||
|
query: {},
|
||||||
|
headers: {},
|
||||||
|
},
|
||||||
|
} as unknown as AuthenticatedSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockAuthService() {
|
||||||
|
return {
|
||||||
|
verifySession: vi.fn().mockResolvedValue({
|
||||||
|
user: { id: "user-123" },
|
||||||
|
session: { id: "session-123" },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockPrismaService() {
|
||||||
|
return {
|
||||||
|
workspaceMember: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockTerminalService() {
|
||||||
|
return {
|
||||||
|
createSession: vi.fn().mockReturnValue({
|
||||||
|
sessionId: "session-uuid-1",
|
||||||
|
name: undefined,
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
}),
|
||||||
|
writeToSession: vi.fn(),
|
||||||
|
resizeSession: vi.fn(),
|
||||||
|
closeSession: vi.fn().mockReturnValue(true),
|
||||||
|
closeWorkspaceSessions: vi.fn(),
|
||||||
|
sessionBelongsToWorkspace: vi.fn().mockReturnValue(true),
|
||||||
|
getWorkspaceSessionCount: vi.fn().mockReturnValue(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tests
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("TerminalGateway", () => {
|
||||||
|
let gateway: TerminalGateway;
|
||||||
|
let mockAuthService: ReturnType<typeof createMockAuthService>;
|
||||||
|
let mockPrismaService: ReturnType<typeof createMockPrismaService>;
|
||||||
|
let mockTerminalService: ReturnType<typeof createMockTerminalService>;
|
||||||
|
let mockClient: AuthenticatedSocket;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockAuthService = createMockAuthService();
|
||||||
|
mockPrismaService = createMockPrismaService();
|
||||||
|
mockTerminalService = createMockTerminalService();
|
||||||
|
mockClient = createMockSocket();
|
||||||
|
|
||||||
|
gateway = new TerminalGateway(
|
||||||
|
mockAuthService as unknown as AuthService,
|
||||||
|
mockPrismaService as unknown as PrismaService,
|
||||||
|
mockTerminalService as unknown as TerminalService
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// handleConnection (authentication)
|
||||||
|
// ==========================================
|
||||||
|
describe("handleConnection", () => {
|
||||||
|
it("should authenticate client and join workspace room on valid token", async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({
|
||||||
|
user: { id: "user-123" },
|
||||||
|
});
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
|
||||||
|
expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token");
|
||||||
|
expect(mockClient.data.userId).toBe("user-123");
|
||||||
|
expect(mockClient.data.workspaceId).toBe("workspace-456");
|
||||||
|
expect(mockClient.join).toHaveBeenCalledWith("terminal:workspace-456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disconnect and emit error if no token provided", async () => {
|
||||||
|
const clientNoToken = createMockSocket("no-token");
|
||||||
|
clientNoToken.handshake = {
|
||||||
|
auth: {},
|
||||||
|
query: {},
|
||||||
|
headers: {},
|
||||||
|
} as typeof clientNoToken.handshake;
|
||||||
|
|
||||||
|
await gateway.handleConnection(clientNoToken);
|
||||||
|
|
||||||
|
expect(clientNoToken.disconnect).toHaveBeenCalled();
|
||||||
|
expect(clientNoToken.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("no token") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disconnect and emit error if token is invalid", async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
|
||||||
|
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("invalid") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disconnect and emit error if no workspace access", async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
|
||||||
|
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("workspace") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disconnect and emit error if auth throws", async () => {
|
||||||
|
mockAuthService.verifySession.mockRejectedValue(new Error("Auth service down"));
|
||||||
|
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
|
||||||
|
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.any(String) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract token from handshake.query as fallback", async () => {
|
||||||
|
const clientQueryToken = createMockSocket("query-token-client");
|
||||||
|
clientQueryToken.handshake = {
|
||||||
|
auth: {},
|
||||||
|
query: { token: "query-token" },
|
||||||
|
headers: {},
|
||||||
|
} as typeof clientQueryToken.handshake;
|
||||||
|
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
|
||||||
|
await gateway.handleConnection(clientQueryToken);
|
||||||
|
|
||||||
|
expect(mockAuthService.verifySession).toHaveBeenCalledWith("query-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract token from Authorization header as last fallback", async () => {
|
||||||
|
const clientHeaderToken = createMockSocket("header-token-client");
|
||||||
|
clientHeaderToken.handshake = {
|
||||||
|
auth: {},
|
||||||
|
query: {},
|
||||||
|
headers: { authorization: "Bearer header-token" },
|
||||||
|
} as typeof clientHeaderToken.handshake;
|
||||||
|
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
|
||||||
|
await gateway.handleConnection(clientHeaderToken);
|
||||||
|
|
||||||
|
expect(mockAuthService.verifySession).toHaveBeenCalledWith("header-token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// handleDisconnect
|
||||||
|
// ==========================================
|
||||||
|
describe("handleDisconnect", () => {
|
||||||
|
it("should close all workspace sessions on disconnect", async () => {
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
gateway.handleDisconnect(mockClient);
|
||||||
|
|
||||||
|
expect(mockTerminalService.closeWorkspaceSessions).toHaveBeenCalledWith("workspace-456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw for unauthenticated client disconnect", () => {
|
||||||
|
const unauthClient = createMockSocket("unauth-disconnect");
|
||||||
|
|
||||||
|
expect(() => gateway.handleDisconnect(unauthClient)).not.toThrow();
|
||||||
|
expect(mockTerminalService.closeWorkspaceSessions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// handleCreate (terminal:create)
|
||||||
|
// ==========================================
|
||||||
|
describe("handleCreate", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a PTY session and emit terminal:created", async () => {
|
||||||
|
mockTerminalService.createSession.mockReturnValue({
|
||||||
|
sessionId: "new-session-id",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
await gateway.handleCreate(mockClient, {});
|
||||||
|
|
||||||
|
expect(mockTerminalService.createSession).toHaveBeenCalled();
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:created",
|
||||||
|
expect.objectContaining({ sessionId: "new-session-id" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass cols, rows, cwd, name to service", async () => {
|
||||||
|
await gateway.handleCreate(mockClient, {
|
||||||
|
cols: 132,
|
||||||
|
rows: 50,
|
||||||
|
cwd: "/home/user",
|
||||||
|
name: "my-shell",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTerminalService.createSession).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ cols: 132, rows: 50, cwd: "/home/user", name: "my-shell" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if not authenticated", async () => {
|
||||||
|
const unauthClient = createMockSocket("unauth");
|
||||||
|
|
||||||
|
await gateway.handleCreate(unauthClient, {});
|
||||||
|
|
||||||
|
expect(unauthClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("authenticated") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if service throws (session limit)", async () => {
|
||||||
|
mockTerminalService.createSession.mockImplementation(() => {
|
||||||
|
throw new Error("Workspace has reached the maximum of 10 concurrent terminal sessions");
|
||||||
|
});
|
||||||
|
|
||||||
|
await gateway.handleCreate(mockClient, {});
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("maximum") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error for invalid payload (negative cols)", async () => {
|
||||||
|
await gateway.handleCreate(mockClient, { cols: -1 });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// handleInput (terminal:input)
|
||||||
|
// ==========================================
|
||||||
|
describe("handleInput", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should write data to the PTY session", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||||
|
|
||||||
|
await gateway.handleInput(mockClient, { sessionId: "sess-1", data: "ls\n" });
|
||||||
|
|
||||||
|
expect(mockTerminalService.writeToSession).toHaveBeenCalledWith("sess-1", "ls\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if session does not belong to workspace", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
||||||
|
|
||||||
|
await gateway.handleInput(mockClient, { sessionId: "alien-sess", data: "data" });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||||
|
);
|
||||||
|
expect(mockTerminalService.writeToSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if not authenticated", async () => {
|
||||||
|
const unauthClient = createMockSocket("unauth");
|
||||||
|
|
||||||
|
await gateway.handleInput(unauthClient, { sessionId: "sess-1", data: "x" });
|
||||||
|
|
||||||
|
expect(unauthClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("authenticated") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
|
||||||
|
await gateway.handleInput(mockClient, { data: "some input" });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// handleResize (terminal:resize)
|
||||||
|
// ==========================================
|
||||||
|
describe("handleResize", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resize the PTY session", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||||
|
|
||||||
|
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 120, rows: 40 });
|
||||||
|
|
||||||
|
expect(mockTerminalService.resizeSession).toHaveBeenCalledWith("sess-1", 120, 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if session does not belong to workspace", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
||||||
|
|
||||||
|
await gateway.handleResize(mockClient, { sessionId: "alien-sess", cols: 80, rows: 24 });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error for invalid payload (cols too large)", async () => {
|
||||||
|
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 9999, rows: 24 });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// handleClose (terminal:close)
|
||||||
|
// ==========================================
|
||||||
|
describe("handleClose", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||||
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||||
|
userId: "user-123",
|
||||||
|
workspaceId: "workspace-456",
|
||||||
|
role: "MEMBER",
|
||||||
|
});
|
||||||
|
await gateway.handleConnection(mockClient);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should close an existing PTY session", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||||
|
mockTerminalService.closeSession.mockReturnValue(true);
|
||||||
|
|
||||||
|
await gateway.handleClose(mockClient, { sessionId: "sess-1" });
|
||||||
|
|
||||||
|
expect(mockTerminalService.closeSession).toHaveBeenCalledWith("sess-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if session does not belong to workspace", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
||||||
|
|
||||||
|
await gateway.handleClose(mockClient, { sessionId: "alien-sess" });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error if closeSession returns false (session gone)", async () => {
|
||||||
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||||
|
mockTerminalService.closeSession.mockReturnValue(false);
|
||||||
|
|
||||||
|
await gateway.handleClose(mockClient, { sessionId: "gone-sess" });
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
|
||||||
|
await gateway.handleClose(mockClient, {});
|
||||||
|
|
||||||
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||||
|
"terminal:error",
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
423
apps/api/src/terminal/terminal.gateway.ts
Normal file
423
apps/api/src/terminal/terminal.gateway.ts
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
/**
|
||||||
|
* TerminalGateway
|
||||||
|
*
|
||||||
|
* WebSocket gateway for real-time PTY terminal sessions.
|
||||||
|
* Uses the `/terminal` namespace to keep terminal traffic separate
|
||||||
|
* from the main WebSocket gateway.
|
||||||
|
*
|
||||||
|
* Protocol:
|
||||||
|
* 1. Client connects with auth token in handshake
|
||||||
|
* 2. Client emits `terminal:create` to spawn a new PTY session
|
||||||
|
* 3. Server emits `terminal:created` with { sessionId }
|
||||||
|
* 4. Client emits `terminal:input` with { sessionId, data } to send keystrokes
|
||||||
|
* 5. Server emits `terminal:output` with { sessionId, data } for stdout/stderr
|
||||||
|
* 6. Client emits `terminal:resize` with { sessionId, cols, rows } on window resize
|
||||||
|
* 7. Client emits `terminal:close` with { sessionId } to terminate the PTY
|
||||||
|
* 8. Server emits `terminal:exit` with { sessionId, exitCode, signal } on PTY exit
|
||||||
|
*
|
||||||
|
* Authentication:
|
||||||
|
* - Same pattern as websocket.gateway.ts and speech.gateway.ts
|
||||||
|
* - Token extracted from handshake.auth.token / query.token / Authorization header
|
||||||
|
*
|
||||||
|
* Workspace isolation:
|
||||||
|
* - Clients join room `terminal:{workspaceId}` on connect
|
||||||
|
* - Sessions are scoped to workspace; cross-workspace access is denied
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
WebSocketGateway as WSGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
SubscribeMessage,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
} from "@nestjs/websockets";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import { Server, Socket } from "socket.io";
|
||||||
|
import { AuthService } from "../auth/auth.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { TerminalService } from "./terminal.service";
|
||||||
|
import {
|
||||||
|
CreateTerminalDto,
|
||||||
|
TerminalInputDto,
|
||||||
|
TerminalResizeDto,
|
||||||
|
CloseTerminalDto,
|
||||||
|
} from "./terminal.dto";
|
||||||
|
import { validate } from "class-validator";
|
||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Types
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
interface AuthenticatedSocket extends Socket {
|
||||||
|
data: {
|
||||||
|
userId?: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Gateway
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
@WSGateway({
|
||||||
|
namespace: "/terminal",
|
||||||
|
cors: {
|
||||||
|
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server!: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(TerminalGateway.name);
|
||||||
|
private readonly CONNECTION_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly terminalService: TerminalService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Connection lifecycle
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate client on connection using handshake token.
|
||||||
|
* Validates workspace membership and joins the workspace-scoped room.
|
||||||
|
*/
|
||||||
|
async handleConnection(client: Socket): Promise<void> {
|
||||||
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (!authenticatedClient.data.userId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Terminal client ${authenticatedClient.id} timed out during authentication`
|
||||||
|
);
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Authentication timed out.",
|
||||||
|
});
|
||||||
|
authenticatedClient.disconnect();
|
||||||
|
}
|
||||||
|
}, this.CONNECTION_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = this.extractTokenFromHandshake(authenticatedClient);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.logger.warn(`Terminal client ${authenticatedClient.id} connected without token`);
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Authentication failed: no token provided.",
|
||||||
|
});
|
||||||
|
authenticatedClient.disconnect();
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = await this.authService.verifySession(token);
|
||||||
|
|
||||||
|
if (!sessionData) {
|
||||||
|
this.logger.warn(`Terminal client ${authenticatedClient.id} has invalid token`);
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Authentication failed: invalid or expired token.",
|
||||||
|
});
|
||||||
|
authenticatedClient.disconnect();
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = sessionData.user as { id: string };
|
||||||
|
const userId = user.id;
|
||||||
|
|
||||||
|
const workspaceMembership = await this.prisma.workspaceMember.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
select: { workspaceId: true, userId: true, role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspaceMembership) {
|
||||||
|
this.logger.warn(`Terminal user ${userId} has no workspace access`);
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Authentication failed: no workspace access.",
|
||||||
|
});
|
||||||
|
authenticatedClient.disconnect();
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticatedClient.data.userId = userId;
|
||||||
|
authenticatedClient.data.workspaceId = workspaceMembership.workspaceId;
|
||||||
|
|
||||||
|
// Join workspace-scoped terminal room
|
||||||
|
const room = this.getWorkspaceRoom(workspaceMembership.workspaceId);
|
||||||
|
await authenticatedClient.join(room);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.logger.log(
|
||||||
|
`Terminal client ${authenticatedClient.id} connected (user: ${userId}, workspace: ${workspaceMembership.workspaceId})`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.logger.error(
|
||||||
|
`Authentication failed for terminal client ${authenticatedClient.id}:`,
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
);
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Authentication failed: an unexpected error occurred.",
|
||||||
|
});
|
||||||
|
authenticatedClient.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all PTY sessions for this client's workspace on disconnect.
|
||||||
|
*/
|
||||||
|
handleDisconnect(client: Socket): void {
|
||||||
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
|
const { workspaceId, userId } = authenticatedClient.data;
|
||||||
|
|
||||||
|
if (workspaceId) {
|
||||||
|
this.terminalService.closeWorkspaceSessions(workspaceId);
|
||||||
|
|
||||||
|
const room = this.getWorkspaceRoom(workspaceId);
|
||||||
|
void authenticatedClient.leave(room);
|
||||||
|
this.logger.log(
|
||||||
|
`Terminal client ${authenticatedClient.id} disconnected (user: ${userId ?? "unknown"}, workspace: ${workspaceId})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.debug(`Terminal client ${authenticatedClient.id} disconnected (unauthenticated)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Terminal events
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn a new PTY session for the connected client.
|
||||||
|
*
|
||||||
|
* Emits `terminal:created` with { sessionId, name, cols, rows } on success.
|
||||||
|
* Emits `terminal:error` on failure.
|
||||||
|
*/
|
||||||
|
@SubscribeMessage("terminal:create")
|
||||||
|
async handleCreate(client: Socket, payload: unknown): Promise<void> {
|
||||||
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
|
const { userId, workspaceId } = authenticatedClient.data;
|
||||||
|
|
||||||
|
if (!userId || !workspaceId) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Not authenticated. Connect with a valid token.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate DTO
|
||||||
|
const dto = plainToInstance(CreateTerminalDto, payload ?? {});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Invalid payload: ${messages}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = this.terminalService.createSession(authenticatedClient, {
|
||||||
|
workspaceId,
|
||||||
|
socketId: authenticatedClient.id,
|
||||||
|
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||||
|
...(dto.cols !== undefined ? { cols: dto.cols } : {}),
|
||||||
|
...(dto.rows !== undefined ? { rows: dto.rows } : {}),
|
||||||
|
...(dto.cwd !== undefined ? { cwd: dto.cwd } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
authenticatedClient.emit("terminal:created", {
|
||||||
|
sessionId: result.sessionId,
|
||||||
|
name: result.name,
|
||||||
|
cols: result.cols,
|
||||||
|
rows: result.rows,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Terminal session ${result.sessionId} created for client ${authenticatedClient.id} (workspace: ${workspaceId})`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to create terminal session for client ${authenticatedClient.id}: ${message}`
|
||||||
|
);
|
||||||
|
authenticatedClient.emit("terminal:error", { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write input data to an existing PTY session.
|
||||||
|
*
|
||||||
|
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||||
|
*/
|
||||||
|
@SubscribeMessage("terminal:input")
|
||||||
|
async handleInput(client: Socket, payload: unknown): Promise<void> {
|
||||||
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
|
const { userId, workspaceId } = authenticatedClient.data;
|
||||||
|
|
||||||
|
if (!userId || !workspaceId) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Not authenticated. Connect with a valid token.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = plainToInstance(TerminalInputDto, payload ?? {});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Invalid payload: ${messages}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.terminalService.writeToSession(dto.sessionId, dto.data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(`Failed to write to terminal session ${dto.sessionId}: ${message}`);
|
||||||
|
authenticatedClient.emit("terminal:error", { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize an existing PTY session.
|
||||||
|
*
|
||||||
|
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||||
|
*/
|
||||||
|
@SubscribeMessage("terminal:resize")
|
||||||
|
async handleResize(client: Socket, payload: unknown): Promise<void> {
|
||||||
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
|
const { userId, workspaceId } = authenticatedClient.data;
|
||||||
|
|
||||||
|
if (!userId || !workspaceId) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Not authenticated. Connect with a valid token.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = plainToInstance(TerminalResizeDto, payload ?? {});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Invalid payload: ${messages}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.terminalService.resizeSession(dto.sessionId, dto.cols, dto.rows);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(`Failed to resize terminal session ${dto.sessionId}: ${message}`);
|
||||||
|
authenticatedClient.emit("terminal:error", { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill and close an existing PTY session.
|
||||||
|
*
|
||||||
|
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||||
|
*/
|
||||||
|
@SubscribeMessage("terminal:close")
|
||||||
|
async handleClose(client: Socket, payload: unknown): Promise<void> {
|
||||||
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
|
const { userId, workspaceId } = authenticatedClient.data;
|
||||||
|
|
||||||
|
if (!userId || !workspaceId) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: "Not authenticated. Connect with a valid token.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = plainToInstance(CloseTerminalDto, payload ?? {});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Invalid payload: ${messages}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closed = this.terminalService.closeSession(dto.sessionId);
|
||||||
|
if (!closed) {
|
||||||
|
authenticatedClient.emit("terminal:error", {
|
||||||
|
message: `Terminal session ${dto.sessionId} not found.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Terminal session ${dto.sessionId} closed by client ${authenticatedClient.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Private helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract authentication token from Socket.IO handshake.
|
||||||
|
* Checks auth.token, query.token, and Authorization header (in that order).
|
||||||
|
*/
|
||||||
|
private extractTokenFromHandshake(client: Socket): string | undefined {
|
||||||
|
const authToken = client.handshake.auth.token as unknown;
|
||||||
|
if (typeof authToken === "string" && authToken.length > 0) {
|
||||||
|
return authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryToken = client.handshake.query.token as unknown;
|
||||||
|
if (typeof queryToken === "string" && queryToken.length > 0) {
|
||||||
|
return queryToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = client.handshake.headers.authorization as unknown;
|
||||||
|
if (typeof authHeader === "string") {
|
||||||
|
const parts = authHeader.split(" ");
|
||||||
|
const [type, token] = parts;
|
||||||
|
if (type === "Bearer" && token) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the workspace-scoped room name for the terminal namespace.
|
||||||
|
*/
|
||||||
|
private getWorkspaceRoom(workspaceId: string): string {
|
||||||
|
return `terminal:${workspaceId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/api/src/terminal/terminal.module.ts
Normal file
31
apps/api/src/terminal/terminal.module.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* TerminalModule
|
||||||
|
*
|
||||||
|
* NestJS module for WebSocket-based terminal sessions via node-pty.
|
||||||
|
*
|
||||||
|
* Imports:
|
||||||
|
* - AuthModule for WebSocket authentication (verifySession)
|
||||||
|
* - PrismaModule for workspace membership queries and session persistence
|
||||||
|
*
|
||||||
|
* Providers:
|
||||||
|
* - TerminalService: manages PTY session lifecycle (in-memory)
|
||||||
|
* - TerminalSessionService: persists session records to the database
|
||||||
|
* - TerminalGateway: WebSocket gateway on /terminal namespace
|
||||||
|
*
|
||||||
|
* The module does not export providers; terminal sessions are
|
||||||
|
* self-contained within this module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TerminalGateway } from "./terminal.gateway";
|
||||||
|
import { TerminalService } from "./terminal.service";
|
||||||
|
import { TerminalSessionService } from "./terminal-session.service";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, PrismaModule],
|
||||||
|
providers: [TerminalGateway, TerminalService, TerminalSessionService],
|
||||||
|
exports: [TerminalSessionService],
|
||||||
|
})
|
||||||
|
export class TerminalModule {}
|
||||||
339
apps/api/src/terminal/terminal.service.spec.ts
Normal file
339
apps/api/src/terminal/terminal.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
276
apps/api/src/terminal/terminal.service.ts
Normal file
276
apps/api/src/terminal/terminal.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -18,15 +18,30 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^9.0.0",
|
"@dnd-kit/sortable": "^9.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@mosaic/shared": "workspace:*",
|
"@mosaic/shared": "workspace:*",
|
||||||
"@mosaic/ui": "workspace:*",
|
"@mosaic/ui": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.90.20",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
|
"@tiptap/extension-code-block-lowlight": "^3.20.0",
|
||||||
|
"@tiptap/extension-link": "^3.20.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.20.0",
|
||||||
|
"@tiptap/extension-table": "^3.20.0",
|
||||||
|
"@tiptap/extension-table-cell": "^3.20.0",
|
||||||
|
"@tiptap/extension-table-header": "^3.20.0",
|
||||||
|
"@tiptap/extension-table-row": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0",
|
||||||
|
"@tiptap/react": "^3.20.0",
|
||||||
|
"@tiptap/starter-kit": "^3.20.0",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/react": "^12.5.3",
|
"@xyflow/react": "^12.5.3",
|
||||||
"better-auth": "^1.4.17",
|
"better-auth": "^1.4.17",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"elkjs": "^0.9.3",
|
"elkjs": "^0.9.3",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mermaid": "^11.4.1",
|
"mermaid": "^11.4.1",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
@@ -34,7 +49,8 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-grid-layout": "^2.2.2",
|
"react-grid-layout": "^2.2.2",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"socket.io-client": "^4.8.3"
|
"socket.io-client": "^4.8.3",
|
||||||
|
"tiptap-markdown": "^0.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mosaic/config": "workspace:*",
|
"@mosaic/config": "workspace:*",
|
||||||
|
|||||||
@@ -128,12 +128,31 @@ function LoginPageContent(): ReactElement {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const callbackURL =
|
const callbackURL =
|
||||||
typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/";
|
typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/";
|
||||||
signIn.oauth2({ providerId, callbackURL }).catch((err: unknown) => {
|
signIn
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
.oauth2({ providerId, callbackURL })
|
||||||
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
.then((result) => {
|
||||||
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
// BetterAuth returns Data | Error union — check for error or missing redirect URL
|
||||||
setOauthLoading(null);
|
const hasError = "error" in result && result.error;
|
||||||
});
|
const hasUrl = "data" in result && result.data?.url;
|
||||||
|
if (hasError || !hasUrl) {
|
||||||
|
const errObj = hasError ? result.error : null;
|
||||||
|
const message =
|
||||||
|
errObj && typeof errObj === "object" && "message" in errObj
|
||||||
|
? String(errObj.message)
|
||||||
|
: "no redirect URL";
|
||||||
|
console.error(`[Auth] OAuth sign-in failed for ${providerId}:`, message);
|
||||||
|
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
||||||
|
setOauthLoading(null);
|
||||||
|
}
|
||||||
|
// If data.url exists, BetterAuth's client will redirect the browser automatically.
|
||||||
|
// No need to reset loading — the page is navigating away.
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
||||||
|
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
||||||
|
setOauthLoading(null);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCredentialsLogin = useCallback(
|
const handleCredentialsLogin = useCallback(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type { Event } from "@mosaic/shared";
|
||||||
import CalendarPage from "./page";
|
import CalendarPage from "./page";
|
||||||
|
|
||||||
// Mock the Calendar component
|
// Mock the Calendar component
|
||||||
@@ -15,15 +16,94 @@ vi.mock("@/components/calendar/Calendar", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock MosaicSpinner
|
||||||
|
vi.mock("@/components/ui/MosaicSpinner", () => ({
|
||||||
|
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
|
||||||
|
<div data-testid="mosaic-spinner">{label ?? "Loading..."}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useWorkspaceId
|
||||||
|
const mockUseWorkspaceId = vi.fn<() => string | null>();
|
||||||
|
vi.mock("@/lib/hooks", () => ({
|
||||||
|
useWorkspaceId: (): string | null => mockUseWorkspaceId(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock fetchEvents
|
||||||
|
const mockFetchEvents = vi.fn<() => Promise<Event[]>>();
|
||||||
|
vi.mock("@/lib/api/events", () => ({
|
||||||
|
fetchEvents: (...args: unknown[]): Promise<Event[]> => mockFetchEvents(...(args as [])),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fakeEvents: Event[] = [
|
||||||
|
{
|
||||||
|
id: "event-1",
|
||||||
|
title: "Team standup",
|
||||||
|
description: "Daily standup meeting",
|
||||||
|
startTime: new Date("2026-02-20T09:00:00Z"),
|
||||||
|
endTime: new Date("2026-02-20T09:30:00Z"),
|
||||||
|
allDay: false,
|
||||||
|
location: null,
|
||||||
|
recurrence: null,
|
||||||
|
creatorId: "user-1",
|
||||||
|
projectId: null,
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date("2026-01-28"),
|
||||||
|
updatedAt: new Date("2026-01-28"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "event-2",
|
||||||
|
title: "Sprint planning",
|
||||||
|
description: "Bi-weekly sprint planning",
|
||||||
|
startTime: new Date("2026-02-21T14:00:00Z"),
|
||||||
|
endTime: new Date("2026-02-21T15:00:00Z"),
|
||||||
|
allDay: false,
|
||||||
|
location: null,
|
||||||
|
recurrence: null,
|
||||||
|
creatorId: "user-1",
|
||||||
|
projectId: null,
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date("2026-01-28"),
|
||||||
|
updatedAt: new Date("2026-01-28"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "event-3",
|
||||||
|
title: "All-day workshop",
|
||||||
|
description: null,
|
||||||
|
startTime: new Date("2026-02-22T00:00:00Z"),
|
||||||
|
endTime: null,
|
||||||
|
allDay: true,
|
||||||
|
location: "Conference Room A",
|
||||||
|
recurrence: null,
|
||||||
|
creatorId: "user-1",
|
||||||
|
projectId: null,
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date("2026-01-28"),
|
||||||
|
updatedAt: new Date("2026-01-28"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
describe("CalendarPage", (): void => {
|
describe("CalendarPage", (): void => {
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUseWorkspaceId.mockReturnValue("ws-1");
|
||||||
|
mockFetchEvents.mockResolvedValue(fakeEvents);
|
||||||
|
});
|
||||||
|
|
||||||
it("should render the page title", (): void => {
|
it("should render the page title", (): void => {
|
||||||
render(<CalendarPage />);
|
render(<CalendarPage />);
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Calendar");
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Calendar");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show loading state initially", (): void => {
|
it("should show loading state initially", (): void => {
|
||||||
|
// Never resolve so we stay in loading state
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
mockFetchEvents.mockReturnValue(new Promise<Event[]>(() => {}));
|
||||||
render(<CalendarPage />);
|
render(<CalendarPage />);
|
||||||
expect(screen.getByTestId("calendar")).toHaveTextContent("Loading");
|
expect(screen.getByTestId("mosaic-spinner")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render the Calendar with events after loading", async (): Promise<void> => {
|
it("should render the Calendar with events after loading", async (): Promise<void> => {
|
||||||
@@ -43,4 +123,31 @@ describe("CalendarPage", (): void => {
|
|||||||
render(<CalendarPage />);
|
render(<CalendarPage />);
|
||||||
expect(screen.getByText("View your schedule at a glance")).toBeInTheDocument();
|
expect(screen.getByText("View your schedule at a glance")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should show empty state when no events exist", async (): Promise<void> => {
|
||||||
|
mockFetchEvents.mockResolvedValue([]);
|
||||||
|
render(<CalendarPage />);
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("No events scheduled")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show error state on API failure", async (): Promise<void> => {
|
||||||
|
mockFetchEvents.mockRejectedValue(new Error("Network error"));
|
||||||
|
render(<CalendarPage />);
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not fetch when workspace ID is not available", async (): Promise<void> => {
|
||||||
|
mockUseWorkspaceId.mockReturnValue(null);
|
||||||
|
render(<CalendarPage />);
|
||||||
|
|
||||||
|
// Wait a tick to ensure useEffect ran
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(mockFetchEvents).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,57 +3,161 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Calendar } from "@/components/calendar/Calendar";
|
import { Calendar } from "@/components/calendar/Calendar";
|
||||||
import { mockEvents } from "@/lib/api/events";
|
import { fetchEvents } from "@/lib/api/events";
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
import type { Event } from "@mosaic/shared";
|
import type { Event } from "@mosaic/shared";
|
||||||
|
|
||||||
export default function CalendarPage(): ReactElement {
|
export default function CalendarPage(): ReactElement {
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadEvents();
|
if (!workspaceId) {
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadEvents(): Promise<void> {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// TODO: Replace with real API call when backend is ready
|
|
||||||
// const data = await fetchEvents();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
||||||
setEvents(mockEvents);
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "We had trouble loading your calendar. Please try again when you're ready."
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wsId = workspaceId;
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
async function loadEvents(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await fetchEvents(wsId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setEvents(data);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Calendar] Failed to fetch events:", err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "We had trouble loading your calendar. Please try again when you're ready."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadEvents();
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [workspaceId]);
|
||||||
|
|
||||||
|
function handleRetry(): void {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
|
||||||
|
const wsId = workspaceId;
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
fetchEvents(wsId)
|
||||||
|
.then((data) => {
|
||||||
|
setEvents(data);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("[Calendar] Retry failed:", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "We had trouble loading your calendar. Please try again when you're ready."
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
|
Calendar
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "var(--text-muted)" }} className="mt-2">
|
||||||
|
View your schedule at a glance
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<MosaicSpinner label="Loading calendar..." />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error !== null) {
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
|
Calendar
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "var(--text-muted)" }} className="mt-2">
|
||||||
|
View your schedule at a glance
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-6 text-center"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--danger)" }}>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="mt-4 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "var(--surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Calendar</h1>
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
<p className="text-gray-600 mt-2">View your schedule at a glance</p>
|
Calendar
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "var(--text-muted)" }} className="mt-2">
|
||||||
|
View your schedule at a glance
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error !== null ? (
|
{events.length === 0 ? (
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
<div
|
||||||
<p className="text-amber-800">{error}</p>
|
className="rounded-lg p-8 text-center"
|
||||||
<button
|
style={{
|
||||||
onClick={() => void loadEvents()}
|
background: "var(--surface)",
|
||||||
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
border: "1px solid var(--border)",
|
||||||
>
|
}}
|
||||||
Try again
|
>
|
||||||
</button>
|
<p className="text-lg" style={{ color: "var(--text-muted)" }}>
|
||||||
|
No events scheduled
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-2" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Your calendar is clear
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Calendar events={events} isLoading={isLoading} />
|
<Calendar events={events} isLoading={false} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
1436
apps/web/src/app/(authenticated)/files/page.tsx
Normal file
1436
apps/web/src/app/(authenticated)/files/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
765
apps/web/src/app/(authenticated)/kanban/page.tsx
Normal file
765
apps/web/src/app/(authenticated)/kanban/page.tsx
Normal file
@@ -0,0 +1,765 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||||
|
import type {
|
||||||
|
DropResult,
|
||||||
|
DroppableProvided,
|
||||||
|
DraggableProvided,
|
||||||
|
DraggableStateSnapshot,
|
||||||
|
} from "@hello-pangea/dnd";
|
||||||
|
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
|
import { fetchTasks, updateTask, type TaskFilters } from "@/lib/api/tasks";
|
||||||
|
import { fetchProjects, type Project } from "@/lib/api/projects";
|
||||||
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
|
import type { Task } from "@mosaic/shared";
|
||||||
|
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Column configuration
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface ColumnConfig {
|
||||||
|
status: TaskStatus;
|
||||||
|
label: string;
|
||||||
|
accent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS: ColumnConfig[] = [
|
||||||
|
{ status: TaskStatus.NOT_STARTED, label: "To Do", accent: "var(--ms-blue-400)" },
|
||||||
|
{ status: TaskStatus.IN_PROGRESS, label: "In Progress", accent: "var(--ms-amber-400)" },
|
||||||
|
{ status: TaskStatus.PAUSED, label: "Paused", accent: "var(--ms-purple-400)" },
|
||||||
|
{ status: TaskStatus.COMPLETED, label: "Done", accent: "var(--ms-teal-400)" },
|
||||||
|
{ status: TaskStatus.ARCHIVED, label: "Archived", accent: "var(--muted)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRIORITY_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: "", label: "All Priorities" },
|
||||||
|
{ value: TaskPriority.HIGH, label: "High" },
|
||||||
|
{ value: TaskPriority.MEDIUM, label: "Medium" },
|
||||||
|
{ value: TaskPriority.LOW, label: "Low" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Filter select shared styles
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const selectStyle: React.CSSProperties = {
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
outline: "none",
|
||||||
|
minWidth: 130,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
...selectStyle,
|
||||||
|
minWidth: 180,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Priority badge helper
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface PriorityStyle {
|
||||||
|
label: string;
|
||||||
|
bg: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityStyle(priority: TaskPriority): PriorityStyle {
|
||||||
|
switch (priority) {
|
||||||
|
case TaskPriority.HIGH:
|
||||||
|
return { label: "High", bg: "rgba(229,72,77,0.15)", color: "var(--danger)" };
|
||||||
|
case TaskPriority.MEDIUM:
|
||||||
|
return { label: "Medium", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
||||||
|
case TaskPriority.LOW:
|
||||||
|
return { label: "Low", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||||
|
default:
|
||||||
|
return { label: String(priority), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Task Card
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface TaskCardProps {
|
||||||
|
task: Task;
|
||||||
|
provided: DraggableProvided;
|
||||||
|
snapshot: DraggableStateSnapshot;
|
||||||
|
columnAccent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskCard({ task, provided, snapshot, columnAccent }: TaskCardProps): ReactElement {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const priorityStyle = getPriorityStyle(task.priority);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: `1px solid ${hovered || snapshot.isDragging ? columnAccent : "var(--border)"}`,
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
cursor: "grab",
|
||||||
|
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||||
|
boxShadow: snapshot.isDragging ? "var(--shadow-lg)" : "none",
|
||||||
|
...provided.draggableProps.style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
marginBottom: 6,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority badge */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "1px 8px",
|
||||||
|
borderRadius: "var(--r-sm)",
|
||||||
|
background: priorityStyle.bg,
|
||||||
|
color: priorityStyle.color,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{priorityStyle.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{task.description && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
margin: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Kanban Column
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface KanbanColumnProps {
|
||||||
|
config: ColumnConfig;
|
||||||
|
tasks: Task[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function KanbanColumn({ config, tasks }: KanbanColumnProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: 280,
|
||||||
|
maxWidth: 340,
|
||||||
|
flex: "1 0 280px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Column header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: `3px solid ${config.accent}`,
|
||||||
|
padding: "12px 16px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minWidth: 22,
|
||||||
|
height: 22,
|
||||||
|
padding: "0 6px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
background: `color-mix(in srgb, ${config.accent} 15%, transparent)`,
|
||||||
|
color: config.accent,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tasks.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Droppable area */}
|
||||||
|
<Droppable droppableId={config.status}>
|
||||||
|
{(provided: DroppableProvided) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px 12px",
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 80,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tasks.map((task, index) => (
|
||||||
|
<Draggable key={task.id} draggableId={task.id} index={index}>
|
||||||
|
{(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
|
||||||
|
<TaskCard
|
||||||
|
task={task}
|
||||||
|
provided={dragProvided}
|
||||||
|
snapshot={dragSnapshot}
|
||||||
|
columnAccent={config.accent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Filter Bar
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface FilterBarProps {
|
||||||
|
projects: Project[];
|
||||||
|
projectId: string;
|
||||||
|
priority: string;
|
||||||
|
search: string;
|
||||||
|
myTasks: boolean;
|
||||||
|
onProjectChange: (value: string) => void;
|
||||||
|
onPriorityChange: (value: string) => void;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onMyTasksToggle: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
hasActiveFilters: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterBar({
|
||||||
|
projects,
|
||||||
|
projectId,
|
||||||
|
priority,
|
||||||
|
search,
|
||||||
|
myTasks,
|
||||||
|
onProjectChange,
|
||||||
|
onPriorityChange,
|
||||||
|
onSearchChange,
|
||||||
|
onMyTasksToggle,
|
||||||
|
onClear,
|
||||||
|
hasActiveFilters,
|
||||||
|
}: FilterBarProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 8,
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tasks..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e): void => {
|
||||||
|
onSearchChange(e.target.value);
|
||||||
|
}}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Project filter */}
|
||||||
|
<select
|
||||||
|
value={projectId}
|
||||||
|
onChange={(e): void => {
|
||||||
|
onProjectChange(e.target.value);
|
||||||
|
}}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="">All Projects</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Priority filter */}
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e): void => {
|
||||||
|
onPriorityChange(e.target.value);
|
||||||
|
}}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
{PRIORITY_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* My Tasks toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMyTasksToggle}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: myTasks ? "1px solid var(--primary)" : "1px solid var(--border)",
|
||||||
|
background: myTasks ? "var(--primary)" : "transparent",
|
||||||
|
color: myTasks ? "#fff" : "var(--text-2)",
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.12s ease",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
My Tasks
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Clear filters */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Kanban Board Page
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export default function KanbanPage(): ReactElement {
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Read filters from URL params
|
||||||
|
const filterProject = searchParams.get("project") ?? "";
|
||||||
|
const filterPriority = searchParams.get("priority") ?? "";
|
||||||
|
const filterSearch = searchParams.get("q") ?? "";
|
||||||
|
const filterMyTasks = searchParams.get("my") === "1";
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
filterProject !== "" || filterPriority !== "" || filterSearch !== "" || filterMyTasks;
|
||||||
|
|
||||||
|
/** Update a single URL param (preserving others) */
|
||||||
|
const setParam = useCallback(
|
||||||
|
(key: string, value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
router.replace(`/kanban?${params.toString()}`, { scroll: false });
|
||||||
|
},
|
||||||
|
[searchParams, router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleProjectChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setParam("project", value);
|
||||||
|
},
|
||||||
|
[setParam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePriorityChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setParam("priority", value);
|
||||||
|
},
|
||||||
|
[setParam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setParam("q", value);
|
||||||
|
},
|
||||||
|
[setParam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMyTasksToggle = useCallback(() => {
|
||||||
|
setParam("my", filterMyTasks ? "" : "1");
|
||||||
|
}, [setParam, filterMyTasks]);
|
||||||
|
|
||||||
|
const handleClearFilters = useCallback(() => {
|
||||||
|
router.replace("/kanban", { scroll: false });
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
/* --- data fetching --- */
|
||||||
|
|
||||||
|
const loadTasks = useCallback(async (wsId: string | null): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const filters = wsId !== null ? { workspaceId: wsId } : {};
|
||||||
|
const data = await fetchTasks(filters);
|
||||||
|
setTasks(data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Kanban] Failed to fetch tasks:", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Something went wrong loading tasks. You could try again when ready."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const filters: TaskFilters = {};
|
||||||
|
if (workspaceId) filters.workspaceId = workspaceId;
|
||||||
|
const [taskData, projectData] = await Promise.all([
|
||||||
|
fetchTasks(filters),
|
||||||
|
fetchProjects(workspaceId ?? undefined),
|
||||||
|
]);
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
setTasks(taskData);
|
||||||
|
setProjects(projectData);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Kanban] Failed to fetch tasks:", err);
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Something went wrong loading tasks. You could try again when ready."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (!ac.signal.aborted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
ac.abort();
|
||||||
|
};
|
||||||
|
}, [workspaceId]);
|
||||||
|
|
||||||
|
/* --- apply client-side filters --- */
|
||||||
|
|
||||||
|
const filteredTasks = useMemo(() => {
|
||||||
|
let result = tasks;
|
||||||
|
|
||||||
|
if (filterProject) {
|
||||||
|
result = result.filter((t) => t.projectId === filterProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterPriority) {
|
||||||
|
result = result.filter((t) => t.priority === (filterPriority as TaskPriority));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterSearch) {
|
||||||
|
const q = filterSearch.toLowerCase();
|
||||||
|
result = result.filter(
|
||||||
|
(t) => t.title.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterMyTasks) {
|
||||||
|
// "My Tasks" filters to tasks assigned to the current user.
|
||||||
|
// Since we don't have the current userId readily available,
|
||||||
|
// filter by assigneeId being non-null (assigned tasks).
|
||||||
|
// A proper implementation would compare against the logged-in user's ID.
|
||||||
|
result = result.filter((t) => t.assigneeId !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [tasks, filterProject, filterPriority, filterSearch, filterMyTasks]);
|
||||||
|
|
||||||
|
/* --- group tasks by status --- */
|
||||||
|
|
||||||
|
function groupByStatus(allTasks: Task[]): Record<TaskStatus, Task[]> {
|
||||||
|
const grouped: Record<TaskStatus, Task[]> = {
|
||||||
|
[TaskStatus.NOT_STARTED]: [],
|
||||||
|
[TaskStatus.IN_PROGRESS]: [],
|
||||||
|
[TaskStatus.PAUSED]: [],
|
||||||
|
[TaskStatus.COMPLETED]: [],
|
||||||
|
[TaskStatus.ARCHIVED]: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const task of allTasks) {
|
||||||
|
grouped[task.status].push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = groupByStatus(filteredTasks);
|
||||||
|
|
||||||
|
/* --- drag-and-drop handler --- */
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(result: DropResult) => {
|
||||||
|
const { source, destination, draggableId } = result;
|
||||||
|
|
||||||
|
// Dropped outside a droppable area
|
||||||
|
if (!destination) return;
|
||||||
|
|
||||||
|
// Dropped in same position
|
||||||
|
if (source.droppableId === destination.droppableId && source.index === destination.index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = destination.droppableId as TaskStatus;
|
||||||
|
const taskId = draggableId;
|
||||||
|
|
||||||
|
// Optimistic update: move card in local state
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
|
||||||
|
|
||||||
|
// Persist to API
|
||||||
|
const wsId = workspaceId ?? undefined;
|
||||||
|
updateTask(taskId, { status: newStatus }, wsId).catch((err: unknown) => {
|
||||||
|
console.error("[Kanban] Failed to update task status:", err);
|
||||||
|
// Revert on failure by re-fetching
|
||||||
|
void loadTasks(workspaceId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[workspaceId, loadTasks]
|
||||||
|
);
|
||||||
|
|
||||||
|
/* --- retry handler --- */
|
||||||
|
|
||||||
|
function handleRetry(): void {
|
||||||
|
void loadTasks(workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- render --- */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ padding: "32px 24px", minHeight: "100%" }}>
|
||||||
|
{/* Page header */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.875rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--text)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kanban Board
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Visualize and manage task progress across stages
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter bar */}
|
||||||
|
<FilterBar
|
||||||
|
projects={projects}
|
||||||
|
projectId={filterProject}
|
||||||
|
priority={filterPriority}
|
||||||
|
search={filterSearch}
|
||||||
|
myTasks={filterMyTasks}
|
||||||
|
onProjectChange={handleProjectChange}
|
||||||
|
onPriorityChange={handlePriorityChange}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
onMyTasksToggle={handleMyTasksToggle}
|
||||||
|
onClear={handleClearFilters}
|
||||||
|
hasActiveFilters={hasActiveFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<MosaicSpinner label="Loading tasks..." />
|
||||||
|
</div>
|
||||||
|
) : error !== null ? (
|
||||||
|
/* Error state */
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: 32,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--danger)", margin: "0 0 16px" }}>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
background: "var(--danger)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : filteredTasks.length === 0 && tasks.length > 0 ? (
|
||||||
|
/* No results (filtered) */
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: 48,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
|
||||||
|
No tasks match your filters.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
/* Empty state */
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: 48,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
|
||||||
|
No tasks yet. Create some tasks to see them here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Board */
|
||||||
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 16,
|
||||||
|
overflowX: "auto",
|
||||||
|
paddingBottom: 16,
|
||||||
|
minHeight: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{COLUMNS.map((col) => (
|
||||||
|
<KanbanColumn key={col.status} config={col} tasks={grouped[col.status]} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DragDropContext>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,23 +2,25 @@
|
|||||||
|
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared";
|
||||||
import type { EntryStatus } from "@mosaic/shared";
|
import type { EntryStatus } from "@mosaic/shared";
|
||||||
import { EntryList } from "@/components/knowledge/EntryList";
|
import { EntryList } from "@/components/knowledge/EntryList";
|
||||||
import { EntryFilters } from "@/components/knowledge/EntryFilters";
|
import { EntryFilters } from "@/components/knowledge/EntryFilters";
|
||||||
import { ImportExportActions } from "@/components/knowledge";
|
import { ImportExportActions } from "@/components/knowledge";
|
||||||
import { mockEntries, mockTags } from "@/lib/api/knowledge";
|
import { fetchEntries, fetchTags } from "@/lib/api/knowledge";
|
||||||
|
import type { EntriesResponse } from "@/lib/api/knowledge";
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
export default function KnowledgePage(): ReactElement {
|
export default function KnowledgePage(): ReactElement {
|
||||||
// TODO: Replace with real API call when backend is ready
|
// Data state
|
||||||
// const { data: entries, isLoading } = useQuery({
|
const [entries, setEntries] = useState<KnowledgeEntryWithTags[]>([]);
|
||||||
// queryKey: ["knowledge-entries"],
|
const [tags, setTags] = useState<KnowledgeTag[]>([]);
|
||||||
// queryFn: fetchEntries,
|
const [totalEntries, setTotalEntries] = useState(0);
|
||||||
// });
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading] = useState(false);
|
|
||||||
|
|
||||||
// Filter and sort state
|
// Filter and sort state
|
||||||
const [selectedStatus, setSelectedStatus] = useState<EntryStatus | "all">("all");
|
const [selectedStatus, setSelectedStatus] = useState<EntryStatus | "all">("all");
|
||||||
@@ -31,60 +33,65 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 10;
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
// Client-side filtering and sorting
|
// Load tags on mount
|
||||||
const filteredAndSortedEntries = useMemo(() => {
|
useEffect(() => {
|
||||||
let filtered = [...mockEntries];
|
let cancelled = false;
|
||||||
|
|
||||||
// Filter by status
|
fetchTags()
|
||||||
if (selectedStatus !== "all") {
|
.then((result) => {
|
||||||
filtered = filtered.filter((entry) => entry.status === selectedStatus);
|
if (!cancelled) {
|
||||||
}
|
setTags(result);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("Failed to load tags:", err);
|
||||||
|
});
|
||||||
|
|
||||||
// Filter by tag
|
return (): void => {
|
||||||
if (selectedTag !== "all") {
|
cancelled = true;
|
||||||
filtered = filtered.filter((entry) =>
|
};
|
||||||
entry.tags.some((tag: { slug: string }) => tag.slug === selectedTag)
|
}, []);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by search query
|
// Load entries when filters/sort/page change
|
||||||
if (searchQuery.trim()) {
|
const loadEntries = useCallback(async (): Promise<void> => {
|
||||||
const query = searchQuery.toLowerCase();
|
setIsLoading(true);
|
||||||
filtered = filtered.filter(
|
setError(null);
|
||||||
(entry) =>
|
|
||||||
entry.title.toLowerCase().includes(query) ||
|
|
||||||
(entry.summary?.toLowerCase().includes(query) ?? false) ||
|
|
||||||
entry.tags.some((tag: { name: string }): boolean =>
|
|
||||||
tag.name.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort entries
|
try {
|
||||||
filtered.sort((a, b) => {
|
const filters: Record<string, unknown> = {
|
||||||
let comparison = 0;
|
page: currentPage,
|
||||||
|
limit: itemsPerPage,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
|
||||||
if (sortBy === "title") {
|
if (selectedStatus !== "all") {
|
||||||
comparison = a.title.localeCompare(b.title);
|
filters.status = selectedStatus;
|
||||||
} else if (sortBy === "createdAt") {
|
}
|
||||||
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
if (selectedTag !== "all") {
|
||||||
} else {
|
filters.tag = selectedTag;
|
||||||
// updatedAt
|
}
|
||||||
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
if (searchQuery.trim()) {
|
||||||
|
filters.search = searchQuery.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
return sortOrder === "asc" ? comparison : -comparison;
|
const response: EntriesResponse = await fetchEntries(
|
||||||
});
|
filters as Parameters<typeof fetchEntries>[0]
|
||||||
|
);
|
||||||
|
setEntries(response.data);
|
||||||
|
setTotalEntries(response.meta?.total ?? response.data.length);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load entries");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentPage, itemsPerPage, sortBy, sortOrder, selectedStatus, selectedTag, searchQuery]);
|
||||||
|
|
||||||
return filtered;
|
useEffect(() => {
|
||||||
}, [selectedStatus, selectedTag, searchQuery, sortBy, sortOrder]);
|
void loadEntries();
|
||||||
|
}, [loadEntries]);
|
||||||
|
|
||||||
// Pagination
|
const totalPages = Math.max(1, Math.ceil(totalEntries / itemsPerPage));
|
||||||
const totalPages = Math.ceil(filteredAndSortedEntries.length / itemsPerPage);
|
|
||||||
const paginatedEntries = filteredAndSortedEntries.slice(
|
|
||||||
(currentPage - 1) * itemsPerPage,
|
|
||||||
currentPage * itemsPerPage
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reset to page 1 when filters change
|
// Reset to page 1 when filters change
|
||||||
const handleFilterChange = (callback: () => void): void => {
|
const handleFilterChange = (callback: () => void): void => {
|
||||||
@@ -101,6 +108,16 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoading && entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
||||||
|
<div className="flex justify-center items-center py-20">
|
||||||
|
<MosaicSpinner size={48} label="Loading knowledge base..." />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -125,14 +142,37 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<ImportExportActions
|
<ImportExportActions
|
||||||
onImportComplete={() => {
|
onImportComplete={() => {
|
||||||
// TODO: Refresh the entry list when real API is connected
|
void loadEntries();
|
||||||
// For now, this would trigger a refetch of the entries
|
|
||||||
window.location.reload();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="mb-6 p-4 rounded-lg border"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--danger)",
|
||||||
|
background: "rgba(229,72,77,0.08)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm" style={{ color: "var(--danger)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void loadEntries();
|
||||||
|
}}
|
||||||
|
className="mt-2 text-sm font-medium underline"
|
||||||
|
style={{ color: "var(--danger)" }}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<EntryFilters
|
<EntryFilters
|
||||||
selectedStatus={selectedStatus}
|
selectedStatus={selectedStatus}
|
||||||
@@ -140,7 +180,7 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
tags={mockTags}
|
tags={tags}
|
||||||
onStatusChange={(status) => {
|
onStatusChange={(status) => {
|
||||||
handleFilterChange(() => {
|
handleFilterChange(() => {
|
||||||
setSelectedStatus(status);
|
setSelectedStatus(status);
|
||||||
@@ -161,7 +201,7 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
|
|
||||||
{/* Entry list */}
|
{/* Entry list */}
|
||||||
<EntryList
|
<EntryList
|
||||||
entries={paginatedEntries}
|
entries={entries}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
|
|||||||
851
apps/web/src/app/(authenticated)/logs/page.tsx
Normal file
851
apps/web/src/app/(authenticated)/logs/page.tsx
Normal file
@@ -0,0 +1,851 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
|
import { fetchRunnerJobs, fetchJobSteps, RunnerJobStatus } from "@/lib/api/runner-jobs";
|
||||||
|
import type { RunnerJob, JobStep } from "@/lib/api/runner-jobs";
|
||||||
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
|
|
||||||
|
// ─── Constants ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type StatusFilter = "all" | "running" | "completed" | "failed" | "queued";
|
||||||
|
type DateRange = "24h" | "7d" | "30d" | "all";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||||
|
{ value: "all", label: "All statuses" },
|
||||||
|
{ value: "running", label: "Running" },
|
||||||
|
{ value: "completed", label: "Completed" },
|
||||||
|
{ value: "failed", label: "Failed" },
|
||||||
|
{ value: "queued", label: "Queued" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATE_RANGES: { value: DateRange; label: string }[] = [
|
||||||
|
{ value: "24h", label: "Last 24h" },
|
||||||
|
{ value: "7d", label: "7d" },
|
||||||
|
{ value: "30d", label: "30d" },
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_FILTER_TO_ENUM: Record<StatusFilter, RunnerJobStatus[] | undefined> = {
|
||||||
|
all: undefined,
|
||||||
|
running: [RunnerJobStatus.RUNNING],
|
||||||
|
completed: [RunnerJobStatus.COMPLETED],
|
||||||
|
failed: [RunnerJobStatus.FAILED],
|
||||||
|
queued: [RunnerJobStatus.QUEUED, RunnerJobStatus.PENDING],
|
||||||
|
};
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "RUNNING":
|
||||||
|
return "var(--ms-amber-400)";
|
||||||
|
case "COMPLETED":
|
||||||
|
return "var(--ms-teal-400)";
|
||||||
|
case "FAILED":
|
||||||
|
case "CANCELLED":
|
||||||
|
return "var(--danger)";
|
||||||
|
case "QUEUED":
|
||||||
|
case "PENDING":
|
||||||
|
return "var(--ms-blue-400)";
|
||||||
|
default:
|
||||||
|
return "var(--muted)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return "\u2014";
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = Date.now();
|
||||||
|
const diffMs = now - date.getTime();
|
||||||
|
const diffSec = Math.floor(diffMs / 1_000);
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
|
||||||
|
if (diffSec < 60) return "just now";
|
||||||
|
if (diffMin < 60) return `${String(diffMin)}m ago`;
|
||||||
|
if (diffHr < 24) return `${String(diffHr)}h ago`;
|
||||||
|
if (diffDay < 30) return `${String(diffDay)}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(startedAt: string | null, completedAt: string | null): string {
|
||||||
|
if (!startedAt) return "\u2014";
|
||||||
|
const start = new Date(startedAt).getTime();
|
||||||
|
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||||
|
const ms = end - start;
|
||||||
|
if (ms < 1_000) return `${String(ms)}ms`;
|
||||||
|
const sec = Math.floor(ms / 1_000);
|
||||||
|
if (sec < 60) return `${String(sec)}s`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
const remainSec = sec % 60;
|
||||||
|
return `${String(min)}m ${String(remainSec)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStepDuration(durationMs: number | null): string {
|
||||||
|
if (durationMs === null) return "\u2014";
|
||||||
|
if (durationMs < 1_000) return `${String(durationMs)}ms`;
|
||||||
|
const sec = Math.floor(durationMs / 1_000);
|
||||||
|
if (sec < 60) return `${String(sec)}s`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
const remainSec = sec % 60;
|
||||||
|
return `${String(min)}m ${String(remainSec)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinDateRange(dateStr: string, range: DateRange): boolean {
|
||||||
|
if (range === "all") return true;
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = Date.now();
|
||||||
|
const hours = range === "24h" ? 24 : range === "7d" ? 168 : 720;
|
||||||
|
return now - date.getTime() < hours * 60 * 60 * 1_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Status Badge ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }): ReactElement {
|
||||||
|
const color = getStatusColor(status);
|
||||||
|
const isRunning = status === "RUNNING";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "2px 10px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color,
|
||||||
|
background: `color-mix(in srgb, ${color} 15%, transparent)`,
|
||||||
|
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
||||||
|
textTransform: "capitalize",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRunning && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: color,
|
||||||
|
animation: "pulse 1.5s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{status.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Page Component ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function LogsPage(): ReactElement {
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
|
|
||||||
|
// Data state
|
||||||
|
const [jobs, setJobs] = useState<RunnerJob[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Expanded job and steps
|
||||||
|
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
||||||
|
const [jobStepsMap, setJobStepsMap] = useState<Record<string, JobStep[]>>({});
|
||||||
|
const [stepsLoading, setStepsLoading] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange>("7d");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
// Auto-refresh
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// Hover state
|
||||||
|
const [hoveredRowId, setHoveredRowId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ─── Data Loading ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const loadJobs = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const statusEnums = STATUS_FILTER_TO_ENUM[statusFilter];
|
||||||
|
const filters: Parameters<typeof fetchRunnerJobs>[0] = {};
|
||||||
|
if (workspaceId) {
|
||||||
|
filters.workspaceId = workspaceId;
|
||||||
|
}
|
||||||
|
if (statusEnums) {
|
||||||
|
filters.status = statusEnums;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fetchRunnerJobs(filters);
|
||||||
|
setJobs(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Logs] Failed to fetch runner jobs:", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "We had trouble loading jobs. Please try again when you're ready."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [workspaceId, statusFilter]);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
loadJobs()
|
||||||
|
.then(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [loadJobs]);
|
||||||
|
|
||||||
|
// Auto-refresh polling
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoRefresh) {
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
void loadJobs();
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
} else if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [autoRefresh, loadJobs]);
|
||||||
|
|
||||||
|
// ─── Steps Loading ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const toggleExpand = useCallback(
|
||||||
|
(jobId: string) => {
|
||||||
|
if (expandedJobId === jobId) {
|
||||||
|
setExpandedJobId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpandedJobId(jobId);
|
||||||
|
|
||||||
|
// Load steps if not already loaded
|
||||||
|
if (!jobStepsMap[jobId] && !stepsLoading.has(jobId)) {
|
||||||
|
setStepsLoading((prev) => new Set(prev).add(jobId));
|
||||||
|
|
||||||
|
fetchJobSteps(jobId, workspaceId ?? undefined)
|
||||||
|
.then((steps) => {
|
||||||
|
setJobStepsMap((prev) => ({ ...prev, [jobId]: steps }));
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("[Logs] Failed to fetch steps for job:", jobId, err);
|
||||||
|
setJobStepsMap((prev) => ({ ...prev, [jobId]: [] }));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setStepsLoading((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(jobId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[expandedJobId, jobStepsMap, stepsLoading, workspaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Filtering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const filteredJobs = jobs.filter((job) => {
|
||||||
|
// Date range filter
|
||||||
|
if (!isWithinDateRange(job.createdAt, dateRange)) return false;
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
const matchesType = job.type.toLowerCase().includes(q);
|
||||||
|
const matchesId = job.id.toLowerCase().includes(q);
|
||||||
|
if (!matchesType && !matchesId) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Manual Refresh ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleManualRefresh = (): void => {
|
||||||
|
setIsLoading(true);
|
||||||
|
void loadJobs().finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = (): void => {
|
||||||
|
setError(null);
|
||||||
|
handleManualRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Render ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
{/* Pulse animation for running status */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
@keyframes auto-refresh-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* ─── Header ─────────────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
|
Logs & Telemetry
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Runner job history and step-level detail
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
{/* Auto-refresh toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setAutoRefresh((prev) => !prev);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: `1px solid ${autoRefresh ? "var(--ms-teal-400)" : "var(--border)"}`,
|
||||||
|
background: autoRefresh
|
||||||
|
? "color-mix(in srgb, var(--ms-teal-400) 12%, transparent)"
|
||||||
|
: "var(--surface)",
|
||||||
|
color: autoRefresh ? "var(--ms-teal-400)" : "var(--text-muted)",
|
||||||
|
transition: "all 150ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{autoRefresh && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--ms-teal-400)",
|
||||||
|
animation: "pulse 1.5s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
Auto-refresh {autoRefresh ? "on" : "off"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Manual refresh */}
|
||||||
|
<button
|
||||||
|
onClick={handleManualRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: isLoading ? "not-allowed" : "pointer",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
opacity: isLoading ? 0.5 : 1,
|
||||||
|
transition: "all 150ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Filter Bar ─────────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Status filter */}
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatusFilter(e.target.value as StatusFilter);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text)",
|
||||||
|
cursor: "pointer",
|
||||||
|
minWidth: 140,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Date range tabs */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DATE_RANGES.map((range) => (
|
||||||
|
<button
|
||||||
|
key={range.value}
|
||||||
|
onClick={() => {
|
||||||
|
setDateRange(range.value);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: "none",
|
||||||
|
borderRight: "1px solid var(--border)",
|
||||||
|
background: dateRange === range.value ? "var(--primary)" : "var(--surface)",
|
||||||
|
color: dateRange === range.value ? "#fff" : "var(--text-muted)",
|
||||||
|
transition: "all 150ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by job type..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text)",
|
||||||
|
minWidth: 200,
|
||||||
|
flex: "1 1 200px",
|
||||||
|
maxWidth: 320,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Content ────────────────────────────────────────────── */}
|
||||||
|
{isLoading && jobs.length === 0 ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<MosaicSpinner label="Loading jobs..." />
|
||||||
|
</div>
|
||||||
|
) : error !== null ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-6 text-center"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--danger)" }}>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="mt-4 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||||
|
style={{ background: "var(--danger)", cursor: "pointer", border: "none" }}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : filteredJobs.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-8 text-center"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--text-muted)" }}>No jobs found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ─── Job Table ──────────────────────────────────────────── */
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{["Job Type", "Status", "Started", "Duration", "Steps"].map((header) => (
|
||||||
|
<th
|
||||||
|
key={header}
|
||||||
|
style={{
|
||||||
|
padding: "10px 16px",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredJobs.map((job) => {
|
||||||
|
const isExpanded = expandedJobId === job.id;
|
||||||
|
const isHovered = hoveredRowId === job.id;
|
||||||
|
const steps = jobStepsMap[job.id];
|
||||||
|
const isStepsLoading = stepsLoading.has(job.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JobRow
|
||||||
|
key={job.id}
|
||||||
|
job={job}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isHovered={isHovered}
|
||||||
|
steps={steps}
|
||||||
|
isStepsLoading={isStepsLoading}
|
||||||
|
onToggle={() => {
|
||||||
|
toggleExpand(job.id);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHoveredRowId(job.id);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHoveredRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Job Row Component ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function JobRow({
|
||||||
|
job,
|
||||||
|
isExpanded,
|
||||||
|
isHovered,
|
||||||
|
steps,
|
||||||
|
isStepsLoading,
|
||||||
|
onToggle,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
}: {
|
||||||
|
job: RunnerJob;
|
||||||
|
isExpanded: boolean;
|
||||||
|
isHovered: boolean;
|
||||||
|
steps: JobStep[] | undefined;
|
||||||
|
isStepsLoading: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onMouseEnter: () => void;
|
||||||
|
onMouseLeave: () => void;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
onClick={onToggle}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
style={{
|
||||||
|
background: isExpanded
|
||||||
|
? "var(--surface-2)"
|
||||||
|
: isHovered
|
||||||
|
? "var(--surface-2)"
|
||||||
|
: "var(--surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderBottom: isExpanded ? "none" : "1px solid var(--border)",
|
||||||
|
transition: "background 100ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--text)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
transition: "transform 150ms ease",
|
||||||
|
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
{job.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px 16px" }}>
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatRelativeTime(job.startedAt ?? job.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(job.startedAt, job.completedAt)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{steps ? String(steps.length) : "\u2014"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expanded Steps Section */}
|
||||||
|
{isExpanded && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
padding: "12px 16px 12px 48px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isStepsLoading ? (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
|
||||||
|
<MosaicSpinner size={24} label="Loading steps..." />
|
||||||
|
</div>
|
||||||
|
) : !steps || steps.length === 0 ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
padding: "8px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No steps recorded for this job
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{["#", "Name", "Phase", "Status", "Duration"].map((header) => (
|
||||||
|
<th
|
||||||
|
key={header}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{steps
|
||||||
|
.sort((a, b) => a.ordinal - b.ordinal)
|
||||||
|
.map((step) => (
|
||||||
|
<StepRow key={step.id} step={step} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Job error message if failed */}
|
||||||
|
{job.error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
background: "color-mix(in srgb, var(--danger) 8%, transparent)",
|
||||||
|
border: "1px solid color-mix(in srgb, var(--danger) 20%, transparent)",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{job.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step Row Component ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StepRow({ step }: { step: JobStep }): ReactElement {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: hovered ? "color-mix(in srgb, var(--surface) 50%, transparent)" : "transparent",
|
||||||
|
borderBottom: "1px solid color-mix(in srgb, var(--border) 50%, transparent)",
|
||||||
|
transition: "background 100ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(step.ordinal)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.name}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
textTransform: "lowercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.phase}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "6px 12px" }}>
|
||||||
|
<StatusBadge status={step.status} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatStepDuration(step.durationMs)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
apps/web/src/app/(authenticated)/not-found.tsx
Normal file
160
apps/web/src/app/(authenticated)/not-found.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function AuthenticatedNotFound(): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "24px",
|
||||||
|
padding: "48px 40px",
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-xl)",
|
||||||
|
boxShadow: "var(--shadow-md)",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: "420px",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Compass icon in blue-tinted icon well */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
background: "rgba(47, 128, 255, 0.1)",
|
||||||
|
color: "var(--ms-blue-400)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polygon
|
||||||
|
points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="none"
|
||||||
|
opacity="0.3"
|
||||||
|
/>
|
||||||
|
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 404 badge pill */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "4px 12px",
|
||||||
|
borderRadius: "9999px",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
background: "rgba(47, 128, 255, 0.15)",
|
||||||
|
color: "var(--ms-blue-400)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
404
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Heading + description */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
margin: 0,
|
||||||
|
letterSpacing: "-0.01em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Page not found
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: 0,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
This page doesn't exist or you may not have permission to view it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "12px",
|
||||||
|
marginTop: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Primary: Dashboard */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "9px 20px",
|
||||||
|
background: "var(--ms-blue-500)",
|
||||||
|
color: "#ffffff",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: "none",
|
||||||
|
transition: "opacity 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Ghost: Settings */}
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "9px 20px",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: "none",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,85 +1,154 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||||
import DashboardPage from "./page";
|
import DashboardPage from "./page";
|
||||||
|
import * as layoutsApi from "@/lib/api/layouts";
|
||||||
|
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
|
||||||
|
|
||||||
// Mock dashboard widgets
|
// ResizeObserver is not available in jsdom
|
||||||
vi.mock("@/components/dashboard/RecentTasksWidget", () => ({
|
beforeAll((): void => {
|
||||||
RecentTasksWidget: ({
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||||
tasks,
|
observe: vi.fn(),
|
||||||
isLoading,
|
unobserve: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock WidgetGrid to avoid react-grid-layout dependency in tests
|
||||||
|
vi.mock("@/components/widgets/WidgetGrid", () => ({
|
||||||
|
WidgetGrid: ({
|
||||||
|
layout,
|
||||||
|
isEditing,
|
||||||
}: {
|
}: {
|
||||||
tasks: unknown[];
|
layout: WidgetPlacement[];
|
||||||
isLoading: boolean;
|
isEditing?: boolean;
|
||||||
}): React.JSX.Element => (
|
}): React.JSX.Element => (
|
||||||
<div data-testid="recent-tasks">
|
<div data-testid="widget-grid" data-editing={isEditing}>
|
||||||
{isLoading ? "Loading tasks" : `${String(tasks.length)} tasks`}
|
{layout.map((item) => (
|
||||||
|
<div key={item.i} data-testid={`widget-${item.i}`}>
|
||||||
|
{item.i}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/dashboard/UpcomingEventsWidget", () => ({
|
// Mock hooks
|
||||||
UpcomingEventsWidget: ({
|
vi.mock("@/lib/hooks", () => ({
|
||||||
events,
|
useWorkspaceId: (): string | null => "ws-test-123",
|
||||||
isLoading,
|
|
||||||
}: {
|
|
||||||
events: unknown[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}): React.JSX.Element => (
|
|
||||||
<div data-testid="upcoming-events">
|
|
||||||
{isLoading ? "Loading events" : `${String(events.length)} events`}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/dashboard/QuickCaptureWidget", () => ({
|
// Mock layout API
|
||||||
QuickCaptureWidget: (): React.JSX.Element => <div data-testid="quick-capture">Quick Capture</div>,
|
vi.mock("@/lib/api/layouts");
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/dashboard/DomainOverviewWidget", () => ({
|
const mockExistingLayout: UserLayout = {
|
||||||
DomainOverviewWidget: ({
|
id: "layout-1",
|
||||||
tasks,
|
workspaceId: "ws-test-123",
|
||||||
isLoading,
|
userId: "user-1",
|
||||||
}: {
|
name: "Default",
|
||||||
tasks: unknown[];
|
isDefault: true,
|
||||||
isLoading: boolean;
|
layout: [
|
||||||
}): React.JSX.Element => (
|
{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2 },
|
||||||
<div data-testid="domain-overview">
|
{ i: "CalendarWidget-default", x: 4, y: 0, w: 4, h: 2 },
|
||||||
{isLoading ? "Loading overview" : `${String(tasks.length)} tasks overview`}
|
],
|
||||||
</div>
|
metadata: {},
|
||||||
),
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
}));
|
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
};
|
||||||
|
|
||||||
describe("DashboardPage", (): void => {
|
describe("DashboardPage", (): void => {
|
||||||
it("should render the page title", (): void => {
|
beforeEach((): void => {
|
||||||
render(<DashboardPage />);
|
vi.clearAllMocks();
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Dashboard");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show loading state initially", (): void => {
|
it("should render WidgetGrid with saved layout", async (): Promise<void> => {
|
||||||
render(<DashboardPage />);
|
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||||
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("Loading tasks");
|
|
||||||
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("Loading events");
|
|
||||||
expect(screen.getByTestId("domain-overview")).toHaveTextContent("Loading overview");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render all widgets with data after loading", async (): Promise<void> => {
|
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("4 tasks");
|
expect(screen.getByTestId("widget-grid")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("3 events");
|
});
|
||||||
expect(screen.getByTestId("domain-overview")).toHaveTextContent("4 tasks overview");
|
|
||||||
expect(screen.getByTestId("quick-capture")).toBeInTheDocument();
|
expect(screen.getByTestId("widget-TasksWidget-default")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("widget-CalendarWidget-default")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create default layout when none exists", async (): Promise<void> => {
|
||||||
|
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(null);
|
||||||
|
vi.mocked(layoutsApi.createLayout).mockResolvedValue({
|
||||||
|
...mockExistingLayout,
|
||||||
|
layout: [{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(layoutsApi.createLayout).toHaveBeenCalledWith("ws-test-123", {
|
||||||
|
name: "Default",
|
||||||
|
isDefault: true,
|
||||||
|
layout: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ i: "TasksWidget-default" }),
|
||||||
|
]) as WidgetPlacement[],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have proper layout structure", (): void => {
|
it("should show loading spinner initially", (): void => {
|
||||||
const { container } = render(<DashboardPage />);
|
// Never-resolving promise to test loading state
|
||||||
const main = container.querySelector("main");
|
vi.mocked(layoutsApi.fetchDefaultLayout).mockReturnValue(
|
||||||
expect(main).toBeInTheDocument();
|
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving
|
||||||
|
new Promise(() => {})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render the welcome subtitle", (): void => {
|
it("should fall back to default layout on API error", async (): Promise<void> => {
|
||||||
|
vi.mocked(layoutsApi.fetchDefaultLayout).mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
expect(screen.getByText(/Welcome back/)).toBeInTheDocument();
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByTestId("widget-grid")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render Dashboard heading", async (): Promise<void> => {
|
||||||
|
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render Edit Layout button", async (): Promise<void> => {
|
||||||
|
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("Edit Layout")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle edit mode on button click", async (): Promise<void> => {
|
||||||
|
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("Edit Layout")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
act((): void => {
|
||||||
|
screen.getByText("Edit Layout").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Done")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("widget-grid").getAttribute("data-editing")).toBe("true");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,32 +1,242 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics";
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions";
|
import { WidgetGrid } from "@/components/widgets/WidgetGrid";
|
||||||
import { QuickActions } from "@/components/dashboard/QuickActions";
|
import { WidgetPicker } from "@/components/widgets/WidgetPicker";
|
||||||
import { ActivityFeed } from "@/components/dashboard/ActivityFeed";
|
import { WidgetConfigDialog } from "@/components/widgets/WidgetConfigDialog";
|
||||||
import { TokenBudget } from "@/components/dashboard/TokenBudget";
|
import { DEFAULT_LAYOUT } from "@/components/widgets/defaultLayout";
|
||||||
|
import { fetchDefaultLayout, createLayout, updateLayout } from "@/lib/api/layouts";
|
||||||
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
|
|
||||||
export default function DashboardPage(): ReactElement {
|
export default function DashboardPage(): ReactElement {
|
||||||
return (
|
const workspaceId = useWorkspaceId();
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
const [layout, setLayout] = useState<WidgetPlacement[]>(DEFAULT_LAYOUT);
|
||||||
<DashboardMetrics />
|
const [layoutId, setLayoutId] = useState<string | null>(null);
|
||||||
<div
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
style={{
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||||
display: "grid",
|
const [configWidgetId, setConfigWidgetId] = useState<string | null>(null);
|
||||||
gridTemplateColumns: "1fr 320px",
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
gap: 16,
|
|
||||||
}}
|
// Debounce timer for auto-saving layout changes
|
||||||
>
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
|
||||||
<OrchestratorSessions />
|
// Load the user's default layout (or create one)
|
||||||
<QuickActions />
|
useEffect(() => {
|
||||||
</div>
|
if (!workspaceId) {
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
setIsLoading(false);
|
||||||
<ActivityFeed />
|
return;
|
||||||
<TokenBudget />
|
}
|
||||||
|
|
||||||
|
const wsId = workspaceId;
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
async function loadLayout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const existing = await fetchDefaultLayout(wsId);
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
setLayout(existing.layout);
|
||||||
|
setLayoutId(existing.id);
|
||||||
|
} else {
|
||||||
|
const created = await createLayout(wsId, {
|
||||||
|
name: "Default",
|
||||||
|
isDefault: true,
|
||||||
|
layout: DEFAULT_LAYOUT,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- aborted can change during await
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
setLayout(created.layout);
|
||||||
|
setLayoutId(created.id);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Dashboard] Failed to load layout:", err);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadLayout();
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
ac.abort();
|
||||||
|
};
|
||||||
|
}, [workspaceId]);
|
||||||
|
|
||||||
|
// Save layout changes with debounce
|
||||||
|
const saveLayout = useCallback(
|
||||||
|
(newLayout: WidgetPlacement[]) => {
|
||||||
|
if (!workspaceId || !layoutId) return;
|
||||||
|
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
void updateLayout(workspaceId, layoutId, { layout: newLayout }).catch((err: unknown) => {
|
||||||
|
console.error("[Dashboard] Failed to save layout:", err);
|
||||||
|
});
|
||||||
|
}, 800);
|
||||||
|
},
|
||||||
|
[workspaceId, layoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLayoutChange = useCallback(
|
||||||
|
(newLayout: WidgetPlacement[]) => {
|
||||||
|
setLayout(newLayout);
|
||||||
|
saveLayout(newLayout);
|
||||||
|
},
|
||||||
|
[saveLayout]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveWidget = useCallback(
|
||||||
|
(widgetId: string) => {
|
||||||
|
const updated = layout.filter((item) => item.i !== widgetId);
|
||||||
|
setLayout(updated);
|
||||||
|
saveLayout(updated);
|
||||||
|
},
|
||||||
|
[layout, saveLayout]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddWidget = useCallback(
|
||||||
|
(placement: WidgetPlacement) => {
|
||||||
|
const updated = [...layout, placement];
|
||||||
|
setLayout(updated);
|
||||||
|
saveLayout(updated);
|
||||||
|
},
|
||||||
|
[layout, saveLayout]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResetLayout = useCallback((): void => {
|
||||||
|
setLayout(DEFAULT_LAYOUT);
|
||||||
|
saveLayout(DEFAULT_LAYOUT);
|
||||||
|
}, [saveLayout]);
|
||||||
|
|
||||||
|
const handleEditWidget = useCallback((widgetId: string): void => {
|
||||||
|
setConfigWidgetId(widgetId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
|
||||||
|
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm" style={{ color: "var(--muted)" }}>
|
||||||
|
Loading dashboard...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
{/* Dashboard header with edit toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--text)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEditing && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleResetLayout}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(): void => {
|
||||||
|
setIsPickerOpen(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add Widget
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(): void => {
|
||||||
|
setIsEditing((prev) => !prev);
|
||||||
|
if (isEditing) setIsPickerOpen(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: isEditing ? "1px solid var(--primary)" : "1px solid var(--border)",
|
||||||
|
background: isEditing ? "var(--primary)" : "transparent",
|
||||||
|
color: isEditing ? "#fff" : "var(--text-2)",
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditing ? "Done" : "Edit Layout"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Widget grid */}
|
||||||
|
<WidgetGrid
|
||||||
|
layout={layout}
|
||||||
|
onLayoutChange={handleLayoutChange}
|
||||||
|
{...(isEditing && { onRemoveWidget: handleRemoveWidget })}
|
||||||
|
{...(isEditing && { onEditWidget: handleEditWidget })}
|
||||||
|
isEditing={isEditing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Widget config dialog */}
|
||||||
|
{configWidgetId && (
|
||||||
|
<WidgetConfigDialog
|
||||||
|
widgetId={configWidgetId}
|
||||||
|
open
|
||||||
|
onClose={(): void => {
|
||||||
|
setConfigWidgetId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Widget picker drawer */}
|
||||||
|
<WidgetPicker
|
||||||
|
open={isPickerOpen}
|
||||||
|
onClose={(): void => {
|
||||||
|
setIsPickerOpen(false);
|
||||||
|
}}
|
||||||
|
onAddWidget={handleAddWidget}
|
||||||
|
currentLayout={layout}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
467
apps/web/src/app/(authenticated)/profile/page.tsx
Normal file
467
apps/web/src/app/(authenticated)/profile/page.tsx
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
|
import { apiGet } from "@/lib/api/client";
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface UserPreferences {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
theme: string;
|
||||||
|
locale: string;
|
||||||
|
timezone: string | null;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sub-components ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PreferenceRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreferenceRow({ label, value }: PreferenceRowProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "12px 0",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.9rem", color: "var(--text-2)" }}>{label}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--text)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreferencesSkeleton(): ReactElement {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "12px 0",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 80,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--surface-2)",
|
||||||
|
animation: "pulse 1.5s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--surface-2)",
|
||||||
|
animation: "pulse 1.5s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Page Component ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ProfilePage(): ReactElement {
|
||||||
|
const { user, signOut } = useAuth();
|
||||||
|
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
|
||||||
|
const [prefsLoading, setPrefsLoading] = useState(true);
|
||||||
|
const [prefsError, setPrefsError] = useState<string | null>(null);
|
||||||
|
const [signOutHovered, setSignOutHovered] = useState(false);
|
||||||
|
const [settingsHovered, setSettingsHovered] = useState(false);
|
||||||
|
|
||||||
|
const loadPreferences = useCallback(async (): Promise<void> => {
|
||||||
|
setPrefsLoading(true);
|
||||||
|
setPrefsError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiGet<UserPreferences>("/users/me/preferences");
|
||||||
|
setPreferences(data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "Could not load preferences";
|
||||||
|
setPrefsError(message);
|
||||||
|
} finally {
|
||||||
|
setPrefsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadPreferences();
|
||||||
|
}, [loadPreferences]);
|
||||||
|
|
||||||
|
// User initials for avatar fallback
|
||||||
|
const initials = user?.name
|
||||||
|
? user.name
|
||||||
|
.split(" ")
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
: user?.email
|
||||||
|
? (user.email[0] ?? "?").toUpperCase()
|
||||||
|
: "?";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-6">
|
||||||
|
{/* ── Page Header ── */}
|
||||||
|
<div style={{ marginBottom: 32 }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.875rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--text)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: "8px 0 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Your account information and preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── User Info Card ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-xl)",
|
||||||
|
padding: 28,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 20 }}>
|
||||||
|
{/* Avatar (64px) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: user?.image
|
||||||
|
? "none"
|
||||||
|
: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.image ? (
|
||||||
|
<img
|
||||||
|
src={user.image}
|
||||||
|
alt={user.name || user.email || "User avatar"}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#fff",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name, email, role, status */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--text)",
|
||||||
|
margin: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.name ?? "User"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Online indicator */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--success)",
|
||||||
|
boxShadow: "0 0 6px var(--success)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--success)",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Online
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user?.email && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: "4px 0 0 0",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user?.workspaceRole && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
marginTop: 8,
|
||||||
|
padding: "3px 10px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
background: "rgba(47, 128, 255, 0.1)",
|
||||||
|
color: "var(--ms-blue-400)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "capitalize",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.workspaceRole}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Preferences Section ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-xl)",
|
||||||
|
padding: 28,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
margin: "0 0 16px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preferences
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{prefsLoading ? (
|
||||||
|
<PreferencesSkeleton />
|
||||||
|
) : prefsError ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px 20px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
background: "rgba(245, 158, 11, 0.08)",
|
||||||
|
border: "1px solid rgba(245, 158, 11, 0.2)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 500 }}>Preferences unavailable</span>
|
||||||
|
<span style={{ color: "var(--muted)", marginLeft: 8 }}>— {prefsError}</span>
|
||||||
|
</div>
|
||||||
|
) : preferences ? (
|
||||||
|
<div>
|
||||||
|
<PreferenceRow label="Theme" value={preferences.theme} />
|
||||||
|
<PreferenceRow label="Locale" value={preferences.locale} />
|
||||||
|
<PreferenceRow label="Timezone" value={preferences.timezone ?? "Not set"} />
|
||||||
|
{Object.keys(preferences.settings).length > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-2)",
|
||||||
|
margin: "16px 0 8px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Custom Settings
|
||||||
|
</div>
|
||||||
|
{Object.entries(preferences.settings).map(([key, value]) => (
|
||||||
|
<PreferenceRow key={key} label={key} value={String(value)} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No preferences configured yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Account Actions ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-xl)",
|
||||||
|
padding: 28,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
margin: "0 0 16px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Account
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
{/* Settings link */}
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "10px 20px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
background: settingsHovered ? "var(--surface-2)" : "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 0.15s ease, border-color 0.15s ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setSettingsHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setSettingsHovered(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="8" r="2.5" />
|
||||||
|
<path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.05 3.05l1.06 1.06M11.89 11.89l1.06 1.06M3.05 12.95l1.06-1.06M11.89 4.11l1.06-1.06" />
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Sign Out button */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void signOut();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "10px 20px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
background: signOutHovered ? "rgba(239, 68, 68, 0.1)" : "transparent",
|
||||||
|
border: "1px solid var(--danger)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 0.15s ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setSignOutHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setSignOutHovered(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M10 11l4-4-4-4M14 8H6" />
|
||||||
|
</svg>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
809
apps/web/src/app/(authenticated)/projects/page.tsx
Normal file
809
apps/web/src/app/(authenticated)/projects/page.tsx
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import type { ReactElement, SyntheticEvent } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { fetchProjects, createProject, deleteProject, ProjectStatus } from "@/lib/api/projects";
|
||||||
|
import type { Project, CreateProjectDto } from "@/lib/api/projects";
|
||||||
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Status badge helpers
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface StatusStyle {
|
||||||
|
label: string;
|
||||||
|
bg: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusStyle(status: ProjectStatus): StatusStyle {
|
||||||
|
switch (status) {
|
||||||
|
case ProjectStatus.PLANNING:
|
||||||
|
return { label: "Planning", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" };
|
||||||
|
case ProjectStatus.ACTIVE:
|
||||||
|
return { label: "Active", bg: "rgba(20,184,166,0.15)", color: "var(--success)" };
|
||||||
|
case ProjectStatus.PAUSED:
|
||||||
|
return { label: "Paused", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
||||||
|
case ProjectStatus.COMPLETED:
|
||||||
|
return { label: "Completed", bg: "rgba(139,92,246,0.15)", color: "var(--purple)" };
|
||||||
|
case ProjectStatus.ARCHIVED:
|
||||||
|
return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||||
|
default:
|
||||||
|
return { label: String(status), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
ProjectCard
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
project: Project;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onClick: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectCard({ project, onDelete, onClick }: ProjectCardProps): ReactElement {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const status = getStatusStyle(project.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
onClick(project.id);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick(project.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: `1px solid ${hovered ? "var(--primary)" : "var(--border)"}`,
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: 20,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "border-color 0.2s var(--ease)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 12,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header row: name + delete button */}
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "1rem",
|
||||||
|
margin: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
|
<button
|
||||||
|
aria-label={`Delete project ${project.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(project.id);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: "var(--r-sm)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
transition: "color 0.15s, background 0.15s",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--danger)";
|
||||||
|
e.currentTarget.style.background = "rgba(229,72,77,0.1)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--muted)";
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{project.description ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
margin: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: "var(--muted)", fontSize: "0.85rem", margin: 0, fontStyle: "italic" }}>
|
||||||
|
No description
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer: status + timestamps */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginTop: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Status badge */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "2px 10px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
background: status.bg,
|
||||||
|
color: status.color,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Timestamps */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTimestamp(project.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Create Project Dialog
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface CreateDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (data: CreateProjectDto) => Promise<void>;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateProjectDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
}: CreateDialogProps): ReactElement {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function resetForm(): void {
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
setFormError("Project name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: CreateProjectDto = { name: trimmedName };
|
||||||
|
const trimmedDesc = description.trim();
|
||||||
|
if (trimmedDesc) {
|
||||||
|
payload.description = trimmedDesc;
|
||||||
|
}
|
||||||
|
await onSubmit(payload);
|
||||||
|
resetForm();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setFormError(err instanceof Error ? err.message : "Failed to create project.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) resetForm();
|
||||||
|
onOpenChange(isOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
padding: 24,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<span style={{ color: "var(--text)" }}>New Project</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<span style={{ color: "var(--muted)" }}>
|
||||||
|
Give your project a name and optional description.
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
void handleSubmit(e);
|
||||||
|
}}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
{/* Name */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label
|
||||||
|
htmlFor="project-name"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Name <span style={{ color: "var(--danger)" }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="project-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="e.g. Website Redesign"
|
||||||
|
maxLength={255}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label
|
||||||
|
htmlFor="project-description"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="project-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDescription(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="A brief summary of this project..."
|
||||||
|
rows={3}
|
||||||
|
maxLength={10000}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
outline: "none",
|
||||||
|
resize: "vertical",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form error */}
|
||||||
|
{formError !== null && (
|
||||||
|
<p style={{ color: "var(--danger)", fontSize: "0.85rem", margin: "0 0 12px" }}>
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !name.trim()}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
background: "var(--primary)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: isSubmitting || !name.trim() ? "not-allowed" : "pointer",
|
||||||
|
opacity: isSubmitting || !name.trim() ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Creating..." : "Create Project"}
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Delete Confirmation Dialog
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface DeleteDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
projectName: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteConfirmDialog({
|
||||||
|
open,
|
||||||
|
projectName,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
isDeleting,
|
||||||
|
}: DeleteDialogProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<span style={{ color: "var(--text)" }}>Delete Project</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<span style={{ color: "var(--muted)" }}>
|
||||||
|
{"This will permanently delete "}
|
||||||
|
<strong style={{ color: "var(--text)" }}>{projectName}</strong>
|
||||||
|
{". This action cannot be undone."}
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isDeleting}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
background: "var(--danger)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: isDeleting ? "not-allowed" : "pointer",
|
||||||
|
opacity: isDeleting ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDeleting ? "Deleting..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Projects Page
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export default function ProjectsPage(): ReactElement {
|
||||||
|
const router = useRouter();
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
|
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Create dialog state
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
// Delete dialog state
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const loadProjects = useCallback(async (wsId: string | null): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await fetchProjects(wsId ?? undefined);
|
||||||
|
setProjects(data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Projects] Failed to fetch projects:", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Something went wrong loading projects. You could try again when ready."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const wsId = workspaceId;
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await fetchProjects(wsId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setProjects(data);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Projects] Failed to fetch projects:", err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Something went wrong loading projects. You could try again when ready."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [workspaceId]);
|
||||||
|
|
||||||
|
function handleRetry(): void {
|
||||||
|
void loadProjects(workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(data: CreateProjectDto): Promise<void> {
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
await createProject(data, workspaceId ?? undefined);
|
||||||
|
setCreateOpen(false);
|
||||||
|
void loadProjects(workspaceId);
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteRequest(projectId: string): void {
|
||||||
|
const target = projects.find((p) => p.id === projectId);
|
||||||
|
if (target) {
|
||||||
|
setDeleteTarget(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfirm(): Promise<void> {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteProject(deleteTarget.id, workspaceId ?? undefined);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
void loadProjects(workspaceId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Projects] Failed to delete project:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to delete project.");
|
||||||
|
setDeleteTarget(null);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardClick(projectId: string): void {
|
||||||
|
router.push(`/projects/${projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8" style={{ maxWidth: 960 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 32,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.875rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--text)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Projects
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Organize and track your work across different initiatives
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 16px",
|
||||||
|
background: "var(--primary)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<MosaicSpinner label="Loading projects..." />
|
||||||
|
</div>
|
||||||
|
) : error !== null ? (
|
||||||
|
/* Error */
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: 32,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--danger)", margin: "0 0 16px" }}>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
background: "var(--danger)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
/* Empty */
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: 48,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--muted)", margin: "0 0 16px", fontSize: "0.9rem" }}>
|
||||||
|
No projects yet. Create your first project to get started.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 16px",
|
||||||
|
background: "var(--primary)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Create Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Projects grid */
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
onDelete={handleDeleteRequest}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Dialog */}
|
||||||
|
<CreateProjectDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
isSubmitting={isCreating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
projectName={deleteTarget?.name ?? ""}
|
||||||
|
onConfirm={() => {
|
||||||
|
void handleDeleteConfirm();
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
apps/web/src/app/(authenticated)/settings/appearance/page.tsx
Normal file
324
apps/web/src/app/(authenticated)/settings/appearance/page.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
|
import { getAllThemes, type ThemeDefinition } from "@/themes";
|
||||||
|
import { apiPatch } from "@/lib/api/client";
|
||||||
|
|
||||||
|
function ThemeCard({
|
||||||
|
theme,
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
theme: ThemeDefinition;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 12,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
|
||||||
|
border: isActive
|
||||||
|
? "2px solid var(--primary)"
|
||||||
|
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
aria-label={`Select ${theme.name} theme`}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
>
|
||||||
|
{/* Color preview swatches */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 0,
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
overflow: "hidden",
|
||||||
|
height: 48,
|
||||||
|
width: "100%",
|
||||||
|
border: "1px solid rgba(128, 128, 128, 0.15)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{theme.colorPreview.map((color, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme info */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{theme.name}
|
||||||
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--primary)"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polyline points="13 4 6 12 3 9" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: "var(--r-sm)",
|
||||||
|
background: theme.isDark ? "rgba(128,128,128,0.15)" : "rgba(245,158,11,0.12)",
|
||||||
|
color: theme.isDark ? "var(--muted)" : "var(--warn)",
|
||||||
|
fontWeight: 500,
|
||||||
|
marginLeft: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{theme.isDark ? "Dark" : "Light"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: 0,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{theme.description}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemThemeCard({
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 12,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
|
||||||
|
border: isActive
|
||||||
|
? "2px solid var(--primary)"
|
||||||
|
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
aria-label="Use system theme preference"
|
||||||
|
aria-pressed={isActive}
|
||||||
|
>
|
||||||
|
{/* Split preview (dark | light) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 0,
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
overflow: "hidden",
|
||||||
|
height: 48,
|
||||||
|
width: "100%",
|
||||||
|
border: "1px solid rgba(128, 128, 128, 0.15)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, background: "#0f141d" }} />
|
||||||
|
<div style={{ flex: 1, background: "#1b2331" }} />
|
||||||
|
<div style={{ flex: 1, background: "#f0f4fc" }} />
|
||||||
|
<div style={{ flex: 1, background: "#dde4f2" }} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: "linear-gradient(135deg, #2f80ff 50%, #8b5cf6 50%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "var(--text)" }}>System</span>
|
||||||
|
{isActive && (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--primary)"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polyline points="13 4 6 12 3 9" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: "var(--r-sm)",
|
||||||
|
background: "rgba(47, 128, 255, 0.12)",
|
||||||
|
color: "var(--primary-l)",
|
||||||
|
fontWeight: 500,
|
||||||
|
marginLeft: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Auto
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: 0,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Follows your operating system appearance preference
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppearanceSettingsPage(): ReactElement {
|
||||||
|
const { theme: preference, setTheme: setLocalTheme } = useTheme();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const allThemes = getAllThemes();
|
||||||
|
|
||||||
|
const handleThemeSelect = useCallback(
|
||||||
|
async (themeId: string) => {
|
||||||
|
setLocalTheme(themeId);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiPatch("/users/me/preferences", { theme: themeId });
|
||||||
|
} catch {
|
||||||
|
// Theme is still applied locally even if API save fails
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setLocalTheme]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
<span style={{ fontSize: "0.83rem", color: "var(--muted)", margin: "0 6px" }}>/</span>
|
||||||
|
<span style={{ fontSize: "0.83rem", color: "var(--text-2)" }}>Appearance</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page header */}
|
||||||
|
<div style={{ marginBottom: 32 }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.875rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--text)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Appearance
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: "8px 0 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Choose a theme for the Mosaic interface
|
||||||
|
{saving && (
|
||||||
|
<span style={{ marginLeft: 12, color: "var(--primary-l)", fontStyle: "italic" }}>
|
||||||
|
Saving...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme grid */}
|
||||||
|
<div
|
||||||
|
className="grid gap-3"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* System option first */}
|
||||||
|
<SystemThemeCard
|
||||||
|
isActive={preference === "system"}
|
||||||
|
onSelect={() => void handleThemeSelect("system")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* All registered themes */}
|
||||||
|
{allThemes.map((t) => (
|
||||||
|
<ThemeCard
|
||||||
|
key={t.id}
|
||||||
|
theme={t}
|
||||||
|
isActive={preference === t.id}
|
||||||
|
onSelect={() => void handleThemeSelect(t.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
apps/web/src/app/(authenticated)/settings/page.tsx
Normal file
264
apps/web/src/app/(authenticated)/settings/page.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { ReactElement, ReactNode } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface CategoryConfig {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
href: string;
|
||||||
|
accent: string;
|
||||||
|
iconBg: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsCategoryCardProps {
|
||||||
|
category: CategoryConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsCategoryCard({ category }: SettingsCategoryCardProps): ReactElement {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={category.href} style={{ textDecoration: "none" }}>
|
||||||
|
<div
|
||||||
|
onMouseEnter={(): void => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(): void => {
|
||||||
|
setHovered(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: hovered ? "var(--surface-2)" : "var(--surface)",
|
||||||
|
border: `1px solid ${hovered ? category.accent : "var(--border)"}`,
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
padding: 20,
|
||||||
|
transition: "background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
boxShadow: hovered ? "0 4px 16px rgba(0,0,0,0.2)" : "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 12,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon well */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
background: category.iconBg,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: category.accent,
|
||||||
|
transition: "transform 0.15s ease",
|
||||||
|
transform: hovered ? "scale(1.05)" : "scale(1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div style={{ fontSize: "1rem", fontWeight: 700, color: "var(--text)" }}>
|
||||||
|
{category.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
lineHeight: 1.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category.description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
color: hovered ? category.accent : "var(--muted)",
|
||||||
|
fontWeight: 500,
|
||||||
|
marginTop: "auto",
|
||||||
|
transition: "color 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage →
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories: CategoryConfig[] = [
|
||||||
|
{
|
||||||
|
title: "Appearance",
|
||||||
|
description:
|
||||||
|
"Choose a theme for the interface. Switch between Dark, Light, Nord, Dracula, and more.",
|
||||||
|
href: "/settings/appearance",
|
||||||
|
accent: "var(--ms-pink-500)",
|
||||||
|
iconBg: "rgba(236, 72, 153, 0.12)",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="10" cy="10" r="7.5" />
|
||||||
|
<path d="M10 2.5v15" />
|
||||||
|
<path d="M10 2.5a7.5 7.5 0 0 1 0 15" fill="currentColor" opacity="0.15" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Credentials",
|
||||||
|
description:
|
||||||
|
"Securely store and manage API keys, tokens, and passwords used by agents and integrations.",
|
||||||
|
href: "/settings/credentials",
|
||||||
|
accent: "var(--ms-blue-400)",
|
||||||
|
iconBg: "rgba(47, 128, 255, 0.12)",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="5" y="9" width="10" height="8" rx="1.5" />
|
||||||
|
<path d="M7 9V6a3 3 0 0 1 6 0v3" />
|
||||||
|
<circle cx="10" cy="13" r="1" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Domains",
|
||||||
|
description:
|
||||||
|
"Organize tasks and projects by life areas or functional domains within your workspace.",
|
||||||
|
href: "/settings/domains",
|
||||||
|
accent: "var(--ms-teal-400)",
|
||||||
|
iconBg: "rgba(20, 184, 166, 0.12)",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="10" cy="10" r="7.5" />
|
||||||
|
<line x1="2.5" y1="10" x2="17.5" y2="10" />
|
||||||
|
<path d="M10 2.5c2 2.5 3 5 3 7.5s-1 5-3 7.5" />
|
||||||
|
<path d="M10 2.5c-2 2.5-3 5-3 7.5s1 5 3 7.5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "AI Personalities",
|
||||||
|
description:
|
||||||
|
"Customize how the AI assistant communicates \u2014 tone, formality, and response style.",
|
||||||
|
href: "/settings/personalities",
|
||||||
|
accent: "var(--ms-purple-400)",
|
||||||
|
iconBg: "rgba(139, 92, 246, 0.12)",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="10" cy="6" r="3" />
|
||||||
|
<path d="M4 17c0-3.3 2.7-6 6-6s6 2.7 6 6" />
|
||||||
|
<path d="M14 10l1.5 1.5 3-3" stroke="currentColor" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Workspaces",
|
||||||
|
description:
|
||||||
|
"Create and manage workspaces to organize projects and collaborate with your team.",
|
||||||
|
href: "/settings/workspaces",
|
||||||
|
accent: "var(--ms-amber-400)",
|
||||||
|
iconBg: "rgba(245, 158, 11, 0.12)",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="10" cy="10" r="2" />
|
||||||
|
<circle cx="4" cy="5" r="1.5" />
|
||||||
|
<circle cx="16" cy="5" r="1.5" />
|
||||||
|
<circle cx="16" cy="15" r="1.5" />
|
||||||
|
<line x1="8.3" y1="8.7" x2="5.3" y2="6.2" />
|
||||||
|
<line x1="11.7" y1="8.7" x2="14.7" y2="6.2" />
|
||||||
|
<line x1="11.7" y1="11.3" x2="14.7" y2="13.8" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsPage(): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-6">
|
||||||
|
{/* Page header */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.875rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--text)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: "8px 0 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Configure your workspace, credentials, and preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SettingsCategoryCard key={category.href} category={category} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { Task } from "@mosaic/shared";
|
||||||
|
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||||
import TasksPage from "./page";
|
import TasksPage from "./page";
|
||||||
|
|
||||||
// Mock the TaskList component
|
// Mock the TaskList component
|
||||||
@@ -9,21 +12,121 @@ vi.mock("@/components/tasks/TaskList", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock MosaicSpinner
|
||||||
|
vi.mock("@/components/ui/MosaicSpinner", () => ({
|
||||||
|
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
|
||||||
|
<div data-testid="mosaic-spinner">{label ?? "Loading..."}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useWorkspaceId
|
||||||
|
const mockUseWorkspaceId = vi.fn<() => string | null>();
|
||||||
|
vi.mock("@/lib/hooks", () => ({
|
||||||
|
useWorkspaceId: (): string | null => mockUseWorkspaceId(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock fetchTasks
|
||||||
|
const mockFetchTasks = vi.fn<() => Promise<Task[]>>();
|
||||||
|
vi.mock("@/lib/api/tasks", () => ({
|
||||||
|
fetchTasks: (...args: unknown[]): Promise<Task[]> => mockFetchTasks(...(args as [])),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fakeTasks: Task[] = [
|
||||||
|
{
|
||||||
|
id: "task-1",
|
||||||
|
title: "Test task 1",
|
||||||
|
description: "Description 1",
|
||||||
|
status: TaskStatus.IN_PROGRESS,
|
||||||
|
priority: TaskPriority.HIGH,
|
||||||
|
dueDate: new Date("2026-02-01"),
|
||||||
|
creatorId: "user-1",
|
||||||
|
assigneeId: "user-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
projectId: null,
|
||||||
|
parentId: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
metadata: {},
|
||||||
|
completedAt: null,
|
||||||
|
createdAt: new Date("2026-01-28"),
|
||||||
|
updatedAt: new Date("2026-01-28"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-2",
|
||||||
|
title: "Test task 2",
|
||||||
|
description: "Description 2",
|
||||||
|
status: TaskStatus.NOT_STARTED,
|
||||||
|
priority: TaskPriority.MEDIUM,
|
||||||
|
dueDate: new Date("2026-02-02"),
|
||||||
|
creatorId: "user-1",
|
||||||
|
assigneeId: "user-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
projectId: null,
|
||||||
|
parentId: null,
|
||||||
|
sortOrder: 1,
|
||||||
|
metadata: {},
|
||||||
|
completedAt: null,
|
||||||
|
createdAt: new Date("2026-01-28"),
|
||||||
|
updatedAt: new Date("2026-01-28"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
describe("TasksPage", (): void => {
|
describe("TasksPage", (): void => {
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUseWorkspaceId.mockReturnValue("ws-1");
|
||||||
|
mockFetchTasks.mockResolvedValue(fakeTasks);
|
||||||
|
});
|
||||||
|
|
||||||
it("should render the page title", (): void => {
|
it("should render the page title", (): void => {
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks");
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show loading state initially", (): void => {
|
it("should show loading spinner initially", (): void => {
|
||||||
|
// Never resolve so we stay in loading state
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
mockFetchTasks.mockReturnValue(new Promise<Task[]>(() => {}));
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
expect(screen.getByTestId("task-list")).toHaveTextContent("Loading");
|
expect(screen.getByTestId("mosaic-spinner")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render the TaskList with tasks after loading", async (): Promise<void> => {
|
it("should render the TaskList with tasks after loading", async (): Promise<void> => {
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(screen.getByTestId("task-list")).toHaveTextContent("4 tasks");
|
expect(screen.getByTestId("task-list")).toHaveTextContent("2 tasks");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show empty state when no tasks exist", async (): Promise<void> => {
|
||||||
|
mockFetchTasks.mockResolvedValue([]);
|
||||||
|
render(<TasksPage />);
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("No tasks found")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show error state on API failure", async (): Promise<void> => {
|
||||||
|
mockFetchTasks.mockRejectedValue(new Error("Network error"));
|
||||||
|
render(<TasksPage />);
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should retry fetching on retry button click", async (): Promise<void> => {
|
||||||
|
mockFetchTasks.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
render(<TasksPage />);
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
mockFetchTasks.mockResolvedValueOnce(fakeTasks);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByRole("button", { name: /try again/i }));
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByTestId("task-list")).toHaveTextContent("2 tasks");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,4 +140,14 @@ describe("TasksPage", (): void => {
|
|||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
expect(screen.getByText("Organize your work at your own pace")).toBeInTheDocument();
|
expect(screen.getByText("Organize your work at your own pace")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not fetch when workspace ID is not available", async (): Promise<void> => {
|
||||||
|
mockUseWorkspaceId.mockReturnValue(null);
|
||||||
|
render(<TasksPage />);
|
||||||
|
|
||||||
|
// Wait a tick to ensure useEffect ran
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(mockFetchTasks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,57 +4,123 @@ import { useState, useEffect } from "react";
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
import { TaskList } from "@/components/tasks/TaskList";
|
import { TaskList } from "@/components/tasks/TaskList";
|
||||||
import { mockTasks } from "@/lib/api/tasks";
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
|
import { fetchTasks } from "@/lib/api/tasks";
|
||||||
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
import type { Task } from "@mosaic/shared";
|
import type { Task } from "@mosaic/shared";
|
||||||
|
|
||||||
export default function TasksPage(): ReactElement {
|
export default function TasksPage(): ReactElement {
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadTasks();
|
if (!workspaceId) {
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadTasks(): Promise<void> {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// TODO: Replace with real API call when backend is ready
|
|
||||||
// const data = await fetchTasks();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
||||||
setTasks(mockTasks);
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "We had trouble loading your tasks. Please try again when you're ready."
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
async function loadTasks(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const filters = workspaceId !== null ? { workspaceId } : {};
|
||||||
|
const data = await fetchTasks(filters);
|
||||||
|
if (!cancelled) {
|
||||||
|
setTasks(data);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Tasks] Failed to fetch tasks:", err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "We had trouble loading your tasks. Please try again when you're ready."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadTasks();
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [workspaceId]);
|
||||||
|
|
||||||
|
function handleRetry(): void {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
fetchTasks({ workspaceId })
|
||||||
|
.then((data) => {
|
||||||
|
setTasks(data);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("[Tasks] Retry failed:", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "We had trouble loading your tasks. Please try again when you're ready."
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Tasks</h1>
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
<p className="text-gray-600 mt-2">Organize your work at your own pace</p>
|
Tasks
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Organize your work at your own pace
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error !== null ? (
|
{isLoading ? (
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
<div className="flex justify-center py-16">
|
||||||
<p className="text-amber-800">{error}</p>
|
<MosaicSpinner label="Loading tasks..." />
|
||||||
|
</div>
|
||||||
|
) : error !== null ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-6 text-center"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--danger)" }}>{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => void loadTasks()}
|
onClick={handleRetry}
|
||||||
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
className="mt-4 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||||
|
style={{ background: "var(--danger)" }}
|
||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-8 text-center"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--text-muted)" }}>No tasks found</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TaskList tasks={tasks} isLoading={isLoading} />
|
<TaskList tasks={tasks} isLoading={false} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
1085
apps/web/src/app/(authenticated)/workspace/page.tsx
Normal file
1085
apps/web/src/app/(authenticated)/workspace/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -765,6 +765,84 @@ body::before {
|
|||||||
animation: scaleIn 0.1s ease-out;
|
animation: scaleIn 0.1s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Streaming cursor for real-time token rendering */
|
||||||
|
@keyframes streaming-cursor-blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
height: 1em;
|
||||||
|
background-color: rgb(var(--accent-primary));
|
||||||
|
border-radius: 1px;
|
||||||
|
animation: streaming-cursor-blink 1s step-end infinite;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Dashboard Layout — Responsive Grids
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
.metrics-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--ms-cols, 6), 1fr);
|
||||||
|
gap: 0;
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.metrics-strip {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell:nth-child(3n + 1) {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.metrics-strip {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell:nth-child(3n + 1) {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell:nth-child(2n + 1) {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.dash-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
Responsive Typography Adjustments
|
Responsive Typography Adjustments
|
||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
|
|||||||
175
apps/web/src/app/not-found.tsx
Normal file
175
apps/web/src/app/not-found.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "404 — Page Not Found | Mosaic Stack",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotFound(): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: "var(--bg)",
|
||||||
|
padding: "24px",
|
||||||
|
textAlign: "center",
|
||||||
|
gap: "32px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Mosaic logo mark — inline spans replicating the 5-element logo */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
position: "relative",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
role="img"
|
||||||
|
aria-label="Mosaic logo"
|
||||||
|
>
|
||||||
|
{/* Top-left: blue */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 19,
|
||||||
|
height: 19,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--ms-blue-500)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Top-right: purple */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 19,
|
||||||
|
height: 19,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--ms-purple-500)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Bottom-right: teal */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 19,
|
||||||
|
height: 19,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--ms-teal-500)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Bottom-left: amber */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 19,
|
||||||
|
height: 19,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--ms-amber-500)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Center: pink circle */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: 15,
|
||||||
|
height: 15,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--ms-pink-500)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 404 gradient text */}
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: "6rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
margin: 0,
|
||||||
|
background: "linear-gradient(135deg, var(--ms-blue-400), var(--ms-purple-500))",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
backgroundClip: "text",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
404
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Heading + description */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
margin: 0,
|
||||||
|
letterSpacing: "-0.025em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Page not found
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.9375rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: 0,
|
||||||
|
maxWidth: "400px",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
The page you're looking for doesn't exist or has been moved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dashboard link styled as button */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "10px 24px",
|
||||||
|
background: "var(--ms-blue-500)",
|
||||||
|
color: "#ffffff",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: "none",
|
||||||
|
transition: "opacity 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Go to Dashboard
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Subtle status footer */}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: 0,
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
HTTP 404 — Not Found
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
||||||
import { render } from "@testing-library/react";
|
|
||||||
import Home from "./page";
|
|
||||||
|
|
||||||
// Mock Next.js navigation
|
|
||||||
const mockPush = vi.fn();
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: (): {
|
|
||||||
push: typeof mockPush;
|
|
||||||
replace: ReturnType<typeof vi.fn>;
|
|
||||||
prefetch: ReturnType<typeof vi.fn>;
|
|
||||||
} => ({
|
|
||||||
push: mockPush,
|
|
||||||
replace: vi.fn(),
|
|
||||||
prefetch: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock auth context
|
|
||||||
vi.mock("@/lib/auth/auth-context", () => ({
|
|
||||||
useAuth: (): {
|
|
||||||
user: null;
|
|
||||||
isLoading: boolean;
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
signOut: ReturnType<typeof vi.fn>;
|
|
||||||
refreshSession: ReturnType<typeof vi.fn>;
|
|
||||||
} => ({
|
|
||||||
user: null,
|
|
||||||
isLoading: false,
|
|
||||||
isAuthenticated: false,
|
|
||||||
signOut: vi.fn(),
|
|
||||||
refreshSession: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Home", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
mockPush.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render loading spinner", (): void => {
|
|
||||||
const { container } = render(<Home />);
|
|
||||||
// The home page shows a loading spinner while redirecting
|
|
||||||
const spinner = container.querySelector(".animate-spin");
|
|
||||||
expect(spinner).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should redirect unauthenticated users to login", (): void => {
|
|
||||||
render(<Home />);
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactElement } from "react";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
|
||||||
|
|
||||||
export default function Home(): ReactElement {
|
|
||||||
const router = useRouter();
|
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading) {
|
|
||||||
if (isAuthenticated) {
|
|
||||||
router.push("/tasks");
|
|
||||||
} else {
|
|
||||||
router.push("/login");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, isLoading, router]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createRef } from "react";
|
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 { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest";
|
||||||
import { Chat, type ChatRef } from "./Chat";
|
import { Chat, type ChatRef } from "./Chat";
|
||||||
import * as useChatModule from "@/hooks/useChat";
|
import * as useChatModule from "@/hooks/useChat";
|
||||||
import * as useWebSocketModule from "@/hooks/useWebSocket";
|
import * as useWebSocketModule from "@/hooks/useWebSocket";
|
||||||
import * as authModule from "@/lib/auth/auth-context";
|
import * as authModule from "@/lib/auth/auth-context";
|
||||||
|
import * as orchestratorModule from "@/hooks/useOrchestratorCommands";
|
||||||
|
|
||||||
// Mock scrollIntoView (not available in JSDOM)
|
// Mock scrollIntoView (not available in JSDOM)
|
||||||
Element.prototype.scrollIntoView = vi.fn();
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
@@ -39,10 +40,28 @@ vi.mock("./ChatInput", () => ({
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
inputRef: React.RefObject<HTMLTextAreaElement | null>;
|
inputRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||||
}): React.ReactElement => (
|
}): React.ReactElement => (
|
||||||
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
|
<>
|
||||||
Send
|
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
|
||||||
</button>
|
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>;
|
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<
|
const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction<
|
||||||
typeof useWebSocketModule.useWebSocket
|
typeof useWebSocketModule.useWebSocket
|
||||||
>;
|
>;
|
||||||
|
const mockUseOrchestratorCommands = orchestratorModule.useOrchestratorCommands as MockedFunction<
|
||||||
|
typeof orchestratorModule.useOrchestratorCommands
|
||||||
|
>;
|
||||||
|
|
||||||
function createMockUseChatReturn(
|
function createMockUseChatReturn(
|
||||||
overrides: Partial<useChatModule.UseChatReturn> = {}
|
overrides: Partial<useChatModule.UseChatReturn> = {}
|
||||||
@@ -64,10 +86,12 @@ function createMockUseChatReturn(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isStreaming: false,
|
||||||
error: null,
|
error: null,
|
||||||
conversationId: null,
|
conversationId: null,
|
||||||
conversationTitle: null,
|
conversationTitle: null,
|
||||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
|
abortStream: vi.fn(),
|
||||||
loadConversation: vi.fn().mockResolvedValue(undefined),
|
loadConversation: vi.fn().mockResolvedValue(undefined),
|
||||||
startNewConversation: vi.fn(),
|
startNewConversation: vi.fn(),
|
||||||
setMessages: vi.fn(),
|
setMessages: vi.fn(),
|
||||||
@@ -96,6 +120,12 @@ describe("Chat", () => {
|
|||||||
socket: null,
|
socket: null,
|
||||||
connectionError: null,
|
connectionError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Default: no commands intercepted
|
||||||
|
mockUseOrchestratorCommands.mockReturnValue({
|
||||||
|
isCommand: vi.fn().mockReturnValue(false),
|
||||||
|
executeCommand: vi.fn().mockResolvedValue(null),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { useChat } from "@/hooks/useChat";
|
import { useChat } from "@/hooks/useChat";
|
||||||
|
import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands";
|
||||||
import { useWebSocket } from "@/hooks/useWebSocket";
|
import { useWebSocket } from "@/hooks/useWebSocket";
|
||||||
import { MessageList } from "./MessageList";
|
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";
|
import type { Message } from "@/hooks/useChat";
|
||||||
|
|
||||||
export interface ChatRef {
|
export interface ChatRef {
|
||||||
@@ -59,31 +61,37 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
|
|
||||||
const { user, isLoading: authLoading } = useAuth();
|
const { user, isLoading: authLoading } = useAuth();
|
||||||
|
|
||||||
// Use the chat hook for state management
|
// 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 {
|
const {
|
||||||
messages,
|
messages,
|
||||||
isLoading: isChatLoading,
|
isLoading: isChatLoading,
|
||||||
|
isStreaming,
|
||||||
error,
|
error,
|
||||||
conversationId,
|
conversationId,
|
||||||
conversationTitle,
|
conversationTitle,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
abortStream,
|
||||||
loadConversation,
|
loadConversation,
|
||||||
startNewConversation,
|
startNewConversation,
|
||||||
|
setMessages,
|
||||||
clearError,
|
clearError,
|
||||||
} = useChat({
|
} = useChat({
|
||||||
model: "llama3.2",
|
model: selectedModel,
|
||||||
|
temperature,
|
||||||
|
maxTokens,
|
||||||
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connect to WebSocket for real-time updates (when we have a user)
|
const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
|
||||||
const { isConnected: isWsConnected } = useWebSocket(
|
|
||||||
user?.id ?? "", // Use user ID as workspace ID for now
|
const { isCommand, executeCommand } = useOrchestratorCommands();
|
||||||
"", // Token not needed since we use cookies
|
|
||||||
{
|
|
||||||
// Future: Add handlers for chat-related events
|
|
||||||
// onChatMessage: (msg) => { ... }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -91,7 +99,15 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
const quipTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const quipTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const quipIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const quipIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Expose methods to parent via ref
|
// Identify the streaming message (last assistant message while streaming)
|
||||||
|
const streamingMessageId =
|
||||||
|
isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined;
|
||||||
|
|
||||||
|
// 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, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
loadConversation: async (cId: string): Promise<void> => {
|
loadConversation: async (cId: string): Promise<void> => {
|
||||||
await loadConversation(cId);
|
await loadConversation(cId);
|
||||||
@@ -110,7 +126,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages, scrollToBottom]);
|
}, [messages, scrollToBottom]);
|
||||||
|
|
||||||
// Notify parent of conversation changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (conversationId && conversationTitle) {
|
if (conversationId && conversationTitle) {
|
||||||
onConversationChange?.(conversationId, {
|
onConversationChange?.(conversationId, {
|
||||||
@@ -125,34 +140,43 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
}
|
}
|
||||||
}, [conversationId, conversationTitle, initialProjectId, onConversationChange]);
|
}, [conversationId, conversationTitle, initialProjectId, onConversationChange]);
|
||||||
|
|
||||||
// Global keyboard shortcut: Ctrl+/ to focus input
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||||
|
// Cmd/Ctrl + / : Focus input
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inputRef.current?.focus();
|
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);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return (): void => {
|
return (): void => {
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [startNewConversation]);
|
||||||
|
|
||||||
// Show loading quips
|
// Show loading quips only during non-streaming load (initial fetch wait)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isChatLoading) {
|
if (isChatLoading && !isStreaming) {
|
||||||
// Show first quip after 3 seconds
|
|
||||||
quipTimerRef.current = setTimeout(() => {
|
quipTimerRef.current = setTimeout(() => {
|
||||||
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
// Change quip every 5 seconds
|
|
||||||
quipIntervalRef.current = setInterval(() => {
|
quipIntervalRef.current = setInterval(() => {
|
||||||
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
} else {
|
} else {
|
||||||
// Clear timers when loading stops
|
|
||||||
if (quipTimerRef.current) {
|
if (quipTimerRef.current) {
|
||||||
clearTimeout(quipTimerRef.current);
|
clearTimeout(quipTimerRef.current);
|
||||||
quipTimerRef.current = null;
|
quipTimerRef.current = null;
|
||||||
@@ -168,16 +192,41 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
if (quipTimerRef.current) clearTimeout(quipTimerRef.current);
|
if (quipTimerRef.current) clearTimeout(quipTimerRef.current);
|
||||||
if (quipIntervalRef.current) clearInterval(quipIntervalRef.current);
|
if (quipIntervalRef.current) clearInterval(quipIntervalRef.current);
|
||||||
};
|
};
|
||||||
}, [isChatLoading]);
|
}, [isChatLoading, isStreaming]);
|
||||||
|
|
||||||
const handleSendMessage = useCallback(
|
const handleSendMessage = useCallback(
|
||||||
async (content: string) => {
|
async (content: string) => {
|
||||||
|
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);
|
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) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -224,11 +273,17 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
{/* Messages Area */}
|
{/* Messages Area */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="mx-auto max-w-4xl px-4 py-6 lg:px-8">
|
<div className="mx-auto max-w-4xl px-4 py-6 lg:px-8">
|
||||||
<MessageList
|
{isEmptyConversation ? (
|
||||||
messages={messages as (Message & { thinking?: string })[]}
|
<ChatEmptyState onSuggestionClick={handleSuggestionClick} />
|
||||||
isLoading={isChatLoading}
|
) : (
|
||||||
loadingQuip={loadingQuip}
|
<MessageList
|
||||||
/>
|
messages={messages as (Message & { thinking?: string })[]}
|
||||||
|
isLoading={isChatLoading}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
{...(streamingMessageId != null ? { streamingMessageId } : {})}
|
||||||
|
loadingQuip={loadingQuip}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,6 +349,12 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
disabled={isChatLoading || !user}
|
disabled={isChatLoading || !user}
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onStopStreaming={abortStream}
|
||||||
|
onModelChange={setSelectedModel}
|
||||||
|
onTemperatureChange={setTemperature}
|
||||||
|
onMaxTokensChange={setMaxTokens}
|
||||||
|
{...(suggestionValue !== undefined ? { externalValue: suggestionValue } : {})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
103
apps/web/src/components/chat/ChatEmptyState.test.tsx
Normal file
103
apps/web/src/components/chat/ChatEmptyState.test.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
99
apps/web/src/components/chat/ChatEmptyState.tsx
Normal file
99
apps/web/src/components/chat/ChatEmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
486
apps/web/src/components/chat/ChatInput.test.tsx
Normal file
486
apps/web/src/components/chat/ChatInput.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,139 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { KeyboardEvent, RefObject } from "react";
|
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 {
|
interface ChatInputProps {
|
||||||
onSend: (message: string) => void;
|
onSend: (message: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
onStopStreaming?: () => void;
|
||||||
|
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 [message, setMessage] = useState("");
|
||||||
const [version, setVersion] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
interface VersionData {
|
interface VersionData {
|
||||||
version?: string;
|
version?: string;
|
||||||
@@ -24,7 +144,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
|||||||
.then((res) => res.json() as Promise<VersionData>)
|
.then((res) => res.json() as Promise<VersionData>)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.version) {
|
if (data.version) {
|
||||||
// Format as "version+commit" for full build identification
|
|
||||||
const fullVersion = data.commit ? `${data.version}+${data.commit}` : data.version;
|
const fullVersion = data.commit ? `${data.version}+${data.commit}` : data.version;
|
||||||
setVersion(fullVersion);
|
setVersion(fullVersion);
|
||||||
}
|
}
|
||||||
@@ -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(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (message.trim() && !disabled) {
|
if (message.trim() && !disabled && !isStreaming) {
|
||||||
onSend(message);
|
onSend(message);
|
||||||
setMessage("");
|
setMessage("");
|
||||||
}
|
}
|
||||||
}, [message, onSend, disabled]);
|
}, [message, onSend, disabled, isStreaming]);
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
onStopStreaming?.();
|
||||||
|
}, [onStopStreaming]);
|
||||||
|
|
||||||
|
const acceptCommand = useCallback((cmdName: string): void => {
|
||||||
|
setMessage(cmdName + " ");
|
||||||
|
setCommandSuggestions([]);
|
||||||
|
setHighlightedCommandIndex(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
(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) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
// Ctrl/Cmd + Enter to send (alternative)
|
|
||||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[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 characterCount = message.length;
|
||||||
const maxCharacters = 4000;
|
const maxCharacters = 4000;
|
||||||
const isNearLimit = characterCount > maxCharacters * 0.9;
|
const isNearLimit = characterCount > maxCharacters * 0.9;
|
||||||
const isOverLimit = characterCount > maxCharacters;
|
const isOverLimit = characterCount > maxCharacters;
|
||||||
|
const isInputDisabled = disabled ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-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 */}
|
{/* Input Container */}
|
||||||
<div
|
<div
|
||||||
className="relative rounded-lg border transition-all duration-150"
|
className="relative rounded-lg border transition-all duration-150"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "rgb(var(--surface-0))",
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
borderColor: disabled ? "rgb(var(--border-default))" : "rgb(var(--border-strong))",
|
borderColor:
|
||||||
|
isInputDisabled || isStreaming
|
||||||
|
? "rgb(var(--border-default))"
|
||||||
|
: "rgb(var(--border-strong))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -79,8 +607,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
|||||||
setMessage(e.target.value);
|
setMessage(e.target.value);
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type a message..."
|
placeholder={isStreaming ? "AI is responding..." : "Type a message..."}
|
||||||
disabled={disabled}
|
disabled={isInputDisabled || isStreaming}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50"
|
className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50"
|
||||||
style={{
|
style={{
|
||||||
@@ -97,28 +625,47 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
|||||||
aria-describedby="input-help"
|
aria-describedby="input-help"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Send Button */}
|
{/* Send / Stop Button */}
|
||||||
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
||||||
<button
|
{isStreaming ? (
|
||||||
onClick={handleSubmit}
|
<button
|
||||||
disabled={(disabled ?? !message.trim()) || isOverLimit}
|
onClick={handleStop}
|
||||||
className="btn-primary btn-sm rounded-md"
|
className="btn-sm rounded-md flex items-center gap-1.5"
|
||||||
style={{
|
style={{
|
||||||
opacity: disabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
backgroundColor: "rgb(var(--semantic-error))",
|
||||||
}}
|
color: "white",
|
||||||
aria-label="Send message"
|
padding: "0.25rem 0.75rem",
|
||||||
>
|
}}
|
||||||
<svg
|
aria-label="Stop generating"
|
||||||
className="h-4 w-4"
|
title="Stop generating"
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
>
|
||||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
</svg>
|
<rect x="6" y="6" width="12" height="12" rx="1" />
|
||||||
<span className="hidden sm:inline">Send</span>
|
</svg>
|
||||||
</button>
|
<span className="hidden sm:inline text-sm font-medium">Stop</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isInputDisabled || !message.trim() || isOverLimit}
|
||||||
|
className="btn-primary btn-sm rounded-md"
|
||||||
|
style={{
|
||||||
|
opacity: isInputDisabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">Send</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -128,7 +675,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
|||||||
style={{ color: "rgb(var(--text-muted))" }}
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
id="input-help"
|
id="input-help"
|
||||||
>
|
>
|
||||||
{/* Keyboard Shortcuts */}
|
|
||||||
<div className="hidden items-center gap-4 sm:flex">
|
<div className="hidden items-center gap-4 sm:flex">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="kbd">Enter</span>
|
<span className="kbd">Enter</span>
|
||||||
@@ -142,10 +688,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile hint */}
|
|
||||||
<div className="sm:hidden">Tap send or press Enter</div>
|
<div className="sm:hidden">Tap send or press Enter</div>
|
||||||
|
|
||||||
{/* Character Count */}
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -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", () => {
|
describe("responsive design", () => {
|
||||||
it("should render as a sidebar on desktop", () => {
|
it("should render as a sidebar on desktop", () => {
|
||||||
render(<ChatOverlay />);
|
render(<ChatOverlay />);
|
||||||
|
|||||||
@@ -164,6 +164,27 @@ export function ChatOverlay(): React.JSX.Element {
|
|||||||
|
|
||||||
{/* Header Controls */}
|
{/* Header Controls */}
|
||||||
<div className="flex items-center gap-1">
|
<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 */}
|
{/* Minimize Button */}
|
||||||
<button
|
<button
|
||||||
onClick={minimize}
|
onClick={minimize}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { Message } from "@/hooks/useChat";
|
import type { Message } from "@/hooks/useChat";
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
streamingMessageId?: string;
|
||||||
loadingQuip?: string | null;
|
loadingQuip?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +16,6 @@ interface MessageListProps {
|
|||||||
* Extracts <thinking>...</thinking> or <think>...</think> blocks.
|
* Extracts <thinking>...</thinking> or <think>...</think> blocks.
|
||||||
*/
|
*/
|
||||||
function parseThinking(content: string): { thinking: string | null; response: string } {
|
function parseThinking(content: string): { thinking: string | null; response: string } {
|
||||||
// Match <thinking>...</thinking> or <think>...</think> blocks
|
|
||||||
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
|
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
|
||||||
const matches = content.match(thinkingRegex);
|
const matches = content.match(thinkingRegex);
|
||||||
|
|
||||||
@@ -22,14 +23,12 @@ function parseThinking(content: string): { thinking: string | null; response: st
|
|||||||
return { thinking: null, response: content };
|
return { thinking: null, response: content };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract thinking content
|
|
||||||
let thinking = "";
|
let thinking = "";
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
|
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
|
||||||
thinking += innerContent.trim() + "\n";
|
thinking += innerContent.trim() + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove thinking blocks from response
|
|
||||||
const response = content.replace(thinkingRegex, "").trim();
|
const response = content.replace(thinkingRegex, "").trim();
|
||||||
|
|
||||||
const trimmedThinking = thinking.trim();
|
const trimmedThinking = thinking.trim();
|
||||||
@@ -42,25 +41,47 @@ function parseThinking(content: string): { thinking: string | null; response: st
|
|||||||
export function MessageList({
|
export function MessageList({
|
||||||
messages,
|
messages,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isStreaming = false,
|
||||||
|
streamingMessageId,
|
||||||
loadingQuip,
|
loadingQuip,
|
||||||
}: MessageListProps): React.JSX.Element {
|
}: MessageListProps): React.JSX.Element {
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when messages change or streaming tokens arrive
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming || isLoading) {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [messages, isStreaming, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" role="log" aria-label="Chat messages">
|
<div className="space-y-6" role="log" aria-label="Chat messages">
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<MessageBubble key={message.id} message={message} />
|
<MessageBubble
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
isStreaming={isStreaming && message.id === streamingMessageId}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isLoading && <LoadingIndicator {...(loadingQuip != null && { quip: loadingQuip })} />}
|
{isLoading && !isStreaming && (
|
||||||
|
<LoadingIndicator {...(loadingQuip != null && { quip: loadingQuip })} />
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
interface MessageBubbleProps {
|
||||||
|
message: Message;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ message, isStreaming = false }: MessageBubbleProps): React.JSX.Element {
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||||
|
|
||||||
// Parse thinking from content (or use pre-parsed thinking field)
|
|
||||||
const { thinking, response } = message.thinking
|
const { thinking, response } = message.thinking
|
||||||
? { thinking: message.thinking, response: message.content }
|
? { thinking: message.thinking, response: message.content }
|
||||||
: parseThinking(message.content);
|
: parseThinking(message.content);
|
||||||
@@ -73,7 +94,6 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
|||||||
setCopied(false);
|
setCopied(false);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Silently fail - clipboard copy is non-critical
|
|
||||||
void err;
|
void err;
|
||||||
}
|
}
|
||||||
}, [response]);
|
}, [response]);
|
||||||
@@ -106,8 +126,21 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
|||||||
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
|
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||||
{isUser ? "You" : "AI Assistant"}
|
{isUser ? "You" : "AI Assistant"}
|
||||||
</span>
|
</span>
|
||||||
|
{/* Streaming indicator in header */}
|
||||||
|
{!isUser && isStreaming && (
|
||||||
|
<span
|
||||||
|
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--accent-primary) / 0.15)",
|
||||||
|
color: "rgb(var(--accent-primary))",
|
||||||
|
}}
|
||||||
|
aria-label="Streaming"
|
||||||
|
>
|
||||||
|
streaming
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{/* Model indicator for assistant messages */}
|
{/* Model indicator for assistant messages */}
|
||||||
{!isUser && message.model && (
|
{!isUser && message.model && !isStreaming && (
|
||||||
<span
|
<span
|
||||||
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||||
style={{
|
style={{
|
||||||
@@ -200,43 +233,54 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
|||||||
border: isUser ? "none" : "1px solid rgb(var(--border-default))",
|
border: isUser ? "none" : "1px solid rgb(var(--border-default))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{response}</p>
|
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||||
|
{response}
|
||||||
{/* Copy Button - appears on hover */}
|
{/* Blinking cursor during streaming */}
|
||||||
<button
|
{isStreaming && !isUser && (
|
||||||
onClick={handleCopy}
|
<span
|
||||||
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
|
className="streaming-cursor inline-block ml-0.5 align-middle"
|
||||||
style={{
|
aria-hidden="true"
|
||||||
backgroundColor: "rgb(var(--surface-0))",
|
/>
|
||||||
borderColor: "rgb(var(--border-default))",
|
|
||||||
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
|
|
||||||
}}
|
|
||||||
aria-label={copied ? "Copied!" : "Copy message"}
|
|
||||||
title={copied ? "Copied!" : "Copy to clipboard"}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<svg
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<polyline points="20 6 9 17 4 12" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</p>
|
||||||
|
|
||||||
|
{/* Copy Button - hidden while streaming */}
|
||||||
|
{!isStreaming && (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
|
||||||
|
}}
|
||||||
|
aria-label={copied ? "Copied!" : "Copy message"}
|
||||||
|
title={copied ? "Copied!" : "Copy to clipboard"}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { Chat, type ChatRef, type NewConversationData } from "./Chat";
|
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 { MessageList } from "./MessageList";
|
||||||
export { ConversationSidebar, type ConversationSidebarRef } from "./ConversationSidebar";
|
export { ConversationSidebar, type ConversationSidebarRef } from "./ConversationSidebar";
|
||||||
export { BackendStatusBanner } from "./BackendStatusBanner";
|
export { BackendStatusBanner } from "./BackendStatusBanner";
|
||||||
export { ChatOverlay } from "./ChatOverlay";
|
export { ChatOverlay } from "./ChatOverlay";
|
||||||
|
export { ChatEmptyState } from "./ChatEmptyState";
|
||||||
export type { Message } from "@/hooks/useChat";
|
export type { Message } from "@/hooks/useChat";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Card, SectionHeader, Badge } from "@mosaic/ui";
|
import { Card, SectionHeader, Badge } from "@mosaic/ui";
|
||||||
|
import type { RecentActivity } from "@/lib/api/dashboard";
|
||||||
|
|
||||||
type BadgeVariantType =
|
type BadgeVariantType =
|
||||||
| "badge-amber"
|
| "badge-amber"
|
||||||
@@ -10,7 +11,7 @@ type BadgeVariantType =
|
|||||||
| "badge-purple"
|
| "badge-purple"
|
||||||
| "badge-pulse";
|
| "badge-pulse";
|
||||||
|
|
||||||
interface ActivityItem {
|
interface ActivityDisplayItem {
|
||||||
id: string;
|
id: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
iconBg: string;
|
iconBg: string;
|
||||||
@@ -18,82 +19,91 @@ interface ActivityItem {
|
|||||||
highlight: string;
|
highlight: string;
|
||||||
rest: string;
|
rest: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
badge?: {
|
badge?:
|
||||||
text: string;
|
| {
|
||||||
variant: BadgeVariantType;
|
text: string;
|
||||||
|
variant: BadgeVariantType;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityFeedProps {
|
||||||
|
items?: RecentActivity[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Mapping helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function getIconForAction(action: string): { icon: string; iconBg: string } {
|
||||||
|
const lower = action.toLowerCase();
|
||||||
|
if (lower.includes("complet") || lower.includes("finish") || lower.includes("success")) {
|
||||||
|
return { icon: "\u2713", iconBg: "rgba(20,184,166,0.15)" };
|
||||||
|
}
|
||||||
|
if (lower.includes("fail") || lower.includes("error")) {
|
||||||
|
return { icon: "\u2717", iconBg: "rgba(229,72,77,0.15)" };
|
||||||
|
}
|
||||||
|
if (lower.includes("warn") || lower.includes("limit")) {
|
||||||
|
return { icon: "\u26A0", iconBg: "rgba(245,158,11,0.15)" };
|
||||||
|
}
|
||||||
|
if (lower.includes("start") || lower.includes("creat")) {
|
||||||
|
return { icon: "\u2191", iconBg: "rgba(47,128,255,0.15)" };
|
||||||
|
}
|
||||||
|
if (lower.includes("update") || lower.includes("modif")) {
|
||||||
|
return { icon: "\u21BB", iconBg: "rgba(139,92,246,0.15)" };
|
||||||
|
}
|
||||||
|
return { icon: "\u2022", iconBg: "rgba(100,116,139,0.15)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBadgeForAction(action: string): ActivityDisplayItem["badge"] {
|
||||||
|
const lower = action.toLowerCase();
|
||||||
|
if (lower.includes("fail") || lower.includes("error")) {
|
||||||
|
return { text: "error", variant: "badge-red" };
|
||||||
|
}
|
||||||
|
if (lower.includes("warn") || lower.includes("limit")) {
|
||||||
|
return { text: "warn", variant: "badge-amber" };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(isoDate: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(isoDate).getTime();
|
||||||
|
const diffMs = now - then;
|
||||||
|
|
||||||
|
if (Number.isNaN(diffMs) || diffMs < 0) return "just now";
|
||||||
|
|
||||||
|
const minutes = Math.floor(diffMs / 60_000);
|
||||||
|
if (minutes < 1) return "just now";
|
||||||
|
if (minutes < 60) return `${String(minutes)}m ago`;
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${String(hours)}h ago`;
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${String(days)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapActivityToDisplay(activity: RecentActivity): ActivityDisplayItem {
|
||||||
|
const { icon, iconBg } = getIconForAction(activity.action);
|
||||||
|
return {
|
||||||
|
id: activity.id,
|
||||||
|
icon,
|
||||||
|
iconBg,
|
||||||
|
title: "",
|
||||||
|
highlight: activity.entityType,
|
||||||
|
rest: ` ${activity.action} (${activity.entityId})`,
|
||||||
|
timestamp: formatRelativeTime(activity.createdAt),
|
||||||
|
badge: getBadgeForAction(activity.action),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const activityItems: ActivityItem[] = [
|
/* ------------------------------------------------------------------ */
|
||||||
{
|
/* Components */
|
||||||
id: "act-1",
|
/* ------------------------------------------------------------------ */
|
||||||
icon: "✓",
|
|
||||||
iconBg: "rgba(20,184,166,0.15)",
|
|
||||||
title: "",
|
|
||||||
highlight: "planner-agent",
|
|
||||||
rest: " completed task analysis for infra-refactor",
|
|
||||||
timestamp: "2m ago",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "act-2",
|
|
||||||
icon: "⚠",
|
|
||||||
iconBg: "rgba(245,158,11,0.15)",
|
|
||||||
title: "",
|
|
||||||
highlight: "executor-agent",
|
|
||||||
rest: " hit rate limit on Terraform API",
|
|
||||||
timestamp: "5m ago",
|
|
||||||
badge: { text: "warn", variant: "badge-amber" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "act-3",
|
|
||||||
icon: "↑",
|
|
||||||
iconBg: "rgba(47,128,255,0.15)",
|
|
||||||
title: "",
|
|
||||||
highlight: "ORCH-002",
|
|
||||||
rest: " session started for api-v3-migration",
|
|
||||||
timestamp: "12m ago",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "act-4",
|
|
||||||
icon: "✗",
|
|
||||||
iconBg: "rgba(229,72,77,0.15)",
|
|
||||||
title: "",
|
|
||||||
highlight: "migrator-agent",
|
|
||||||
rest: " failed to connect to staging database",
|
|
||||||
timestamp: "18m ago",
|
|
||||||
badge: { text: "error", variant: "badge-red" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "act-5",
|
|
||||||
icon: "✓",
|
|
||||||
iconBg: "rgba(20,184,166,0.15)",
|
|
||||||
title: "",
|
|
||||||
highlight: "reviewer-agent",
|
|
||||||
rest: " approved PR #214 in infra-refactor",
|
|
||||||
timestamp: "34m ago",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "act-6",
|
|
||||||
icon: "⟳",
|
|
||||||
iconBg: "rgba(139,92,246,0.15)",
|
|
||||||
title: "Token budget reset for ",
|
|
||||||
highlight: "gpt-4o",
|
|
||||||
rest: " model",
|
|
||||||
timestamp: "1h ago",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "act-7",
|
|
||||||
icon: "★",
|
|
||||||
iconBg: "rgba(20,184,166,0.15)",
|
|
||||||
title: "Project ",
|
|
||||||
highlight: "data-pipeline",
|
|
||||||
rest: " marked as completed",
|
|
||||||
timestamp: "2h ago",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ActivityItemRowProps {
|
interface ActivityItemRowProps {
|
||||||
item: ActivityItem;
|
item: ActivityDisplayItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
||||||
@@ -102,8 +112,8 @@ function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
|||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
gap: 10,
|
gap: 12,
|
||||||
padding: "8px 0",
|
padding: "10px 0",
|
||||||
borderBottom: "1px solid var(--border)",
|
borderBottom: "1px solid var(--border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -155,14 +165,27 @@ function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityFeed(): ReactElement {
|
export function ActivityFeed({ items }: ActivityFeedProps): ReactElement {
|
||||||
|
const displayItems = items ? items.map(mapActivityToDisplay) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<SectionHeader title="Activity Feed" subtitle="Recent agent events" />
|
<SectionHeader title="Activity Feed" subtitle="Recent agent events" />
|
||||||
<div>
|
<div>
|
||||||
{activityItems.map((item) => (
|
{displayItems.length > 0 ? (
|
||||||
<ActivityItemRow key={item.id} item={item} />
|
displayItems.map((item) => <ActivityItemRow key={item.id} item={item} />)
|
||||||
))}
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No recent activity
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +1,69 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { MetricsStrip, type MetricCell } from "@mosaic/ui";
|
import { MetricsStrip, type MetricCell } from "@mosaic/ui";
|
||||||
|
import type { DashboardMetrics as DashboardMetricsData } from "@/lib/api/dashboard";
|
||||||
|
|
||||||
const cells: MetricCell[] = [
|
export interface DashboardMetricsProps {
|
||||||
{
|
metrics?: DashboardMetricsData | undefined;
|
||||||
label: "Active Agents",
|
}
|
||||||
value: "47",
|
|
||||||
color: "var(--ms-blue-400)",
|
function formatNumber(n: number): string {
|
||||||
trend: { direction: "up", text: "↑ +3 from yesterday" },
|
return n.toLocaleString();
|
||||||
},
|
}
|
||||||
{
|
|
||||||
label: "Tasks Completed",
|
function buildCells(metrics: DashboardMetricsData): MetricCell[] {
|
||||||
value: "1,284",
|
return [
|
||||||
color: "var(--ms-teal-400)",
|
{
|
||||||
trend: { direction: "up", text: "↑ +128 today" },
|
label: "Active Agents",
|
||||||
},
|
value: formatNumber(metrics.activeAgents),
|
||||||
{
|
color: "var(--ms-blue-400)",
|
||||||
label: "Avg Response Time",
|
trend: { direction: "neutral", text: "currently active" },
|
||||||
value: "2.4s",
|
},
|
||||||
color: "var(--ms-purple-400)",
|
{
|
||||||
trend: { direction: "down", text: "↓ -0.3s improved" },
|
label: "Tasks Completed",
|
||||||
},
|
value: formatNumber(metrics.tasksCompleted),
|
||||||
{
|
color: "var(--ms-teal-400)",
|
||||||
label: "Token Usage",
|
trend: { direction: "neutral", text: `of ${formatNumber(metrics.totalTasks)} total` },
|
||||||
value: "3.2M",
|
},
|
||||||
color: "var(--ms-amber-400)",
|
{
|
||||||
trend: { direction: "neutral", text: "78% of budget" },
|
label: "Total Tasks",
|
||||||
},
|
value: formatNumber(metrics.totalTasks),
|
||||||
{
|
color: "var(--ms-purple-400)",
|
||||||
label: "Error Rate",
|
trend: { direction: "neutral", text: "across workspace" },
|
||||||
value: "0.4%",
|
},
|
||||||
color: "var(--ms-red-400)",
|
{
|
||||||
trend: { direction: "down", text: "↓ -0.1% improved" },
|
label: "In Progress",
|
||||||
},
|
value: formatNumber(metrics.tasksInProgress),
|
||||||
{
|
color: "var(--ms-amber-400)",
|
||||||
label: "Active Projects",
|
trend: { direction: "neutral", text: "tasks running" },
|
||||||
value: "8",
|
},
|
||||||
color: "var(--ms-cyan-500)",
|
{
|
||||||
trend: { direction: "neutral", text: "2 deploying" },
|
label: "Error Rate",
|
||||||
},
|
value: `${String(metrics.errorRate)}%`,
|
||||||
|
color: "var(--ms-red-400)",
|
||||||
|
trend: {
|
||||||
|
direction: metrics.errorRate > 1 ? "up" : "down",
|
||||||
|
text: metrics.errorRate > 1 ? "above threshold" : "within threshold",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Active Projects",
|
||||||
|
value: formatNumber(metrics.activeProjects),
|
||||||
|
color: "var(--ms-cyan-500)",
|
||||||
|
trend: { direction: "neutral", text: "in workspace" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_CELLS: MetricCell[] = [
|
||||||
|
{ label: "Active Agents", value: "0", color: "var(--ms-blue-400)" },
|
||||||
|
{ label: "Tasks Completed", value: "0", color: "var(--ms-teal-400)" },
|
||||||
|
{ label: "Total Tasks", value: "0", color: "var(--ms-purple-400)" },
|
||||||
|
{ label: "In Progress", value: "0", color: "var(--ms-amber-400)" },
|
||||||
|
{ label: "Error Rate", value: "0%", color: "var(--ms-red-400)" },
|
||||||
|
{ label: "Active Projects", value: "0", color: "var(--ms-cyan-500)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DashboardMetrics(): ReactElement {
|
export function DashboardMetrics({ metrics }: DashboardMetricsProps): ReactElement {
|
||||||
|
const cells = metrics ? buildCells(metrics) : EMPTY_CELLS;
|
||||||
return <MetricsStrip cells={cells} />;
|
return <MetricsStrip cells={cells} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import type { Task } from "@mosaic/shared";
|
|
||||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
|
||||||
|
|
||||||
interface DomainOverviewWidgetProps {
|
|
||||||
tasks: Task[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DomainOverviewWidget({
|
|
||||||
tasks,
|
|
||||||
isLoading,
|
|
||||||
}: DomainOverviewWidgetProps): React.JSX.Element {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex justify-center items-center">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
|
||||||
<span className="ml-3 text-gray-600">Loading overview...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
total: tasks.length,
|
|
||||||
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
|
|
||||||
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
|
|
||||||
highPriority: tasks.filter((t) => t.priority === TaskPriority.HIGH).length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const StatCard = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
color: string;
|
|
||||||
}): React.JSX.Element => (
|
|
||||||
<div className={`p-4 rounded-lg bg-gradient-to-br ${color}`}>
|
|
||||||
<div className="text-3xl font-bold text-white mb-1">{value}</div>
|
|
||||||
<div className="text-sm text-white/90">{label}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Domain Overview</h2>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<StatCard label="Total Tasks" value={stats.total} color="from-blue-500 to-blue-600" />
|
|
||||||
<StatCard
|
|
||||||
label="In Progress"
|
|
||||||
value={stats.inProgress}
|
|
||||||
color="from-green-500 to-green-600"
|
|
||||||
/>
|
|
||||||
<StatCard label="Completed" value={stats.completed} color="from-purple-500 to-purple-600" />
|
|
||||||
<StatCard
|
|
||||||
label="High Priority"
|
|
||||||
value={stats.highPriority}
|
|
||||||
color="from-red-500 to-red-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,22 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Card, SectionHeader, Badge, Dot } from "@mosaic/ui";
|
import { Card, SectionHeader, Badge, Dot } from "@mosaic/ui";
|
||||||
|
import type { ActiveJob } from "@/lib/api/dashboard";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Internal display types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type DotVariant = "teal" | "blue" | "amber" | "red" | "muted";
|
||||||
|
|
||||||
|
type BadgeVariant =
|
||||||
|
| "badge-teal"
|
||||||
|
| "badge-amber"
|
||||||
|
| "badge-red"
|
||||||
|
| "badge-blue"
|
||||||
|
| "badge-muted"
|
||||||
|
| "badge-purple"
|
||||||
|
| "badge-pulse";
|
||||||
|
|
||||||
interface AgentNode {
|
interface AgentNode {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,7 +26,8 @@ interface AgentNode {
|
|||||||
avatarColor: string;
|
avatarColor: string;
|
||||||
name: string;
|
name: string;
|
||||||
task: string;
|
task: string;
|
||||||
status: "teal" | "blue" | "amber" | "red" | "muted";
|
status: DotVariant;
|
||||||
|
statusLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrchestratorSession {
|
interface OrchestratorSession {
|
||||||
@@ -18,73 +35,97 @@ interface OrchestratorSession {
|
|||||||
orchId: string;
|
orchId: string;
|
||||||
name: string;
|
name: string;
|
||||||
badge: string;
|
badge: string;
|
||||||
badgeVariant:
|
badgeVariant: BadgeVariant;
|
||||||
| "badge-teal"
|
|
||||||
| "badge-amber"
|
|
||||||
| "badge-red"
|
|
||||||
| "badge-blue"
|
|
||||||
| "badge-muted"
|
|
||||||
| "badge-purple"
|
|
||||||
| "badge-pulse";
|
|
||||||
duration: string;
|
duration: string;
|
||||||
|
progress: number;
|
||||||
agents: AgentNode[];
|
agents: AgentNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions: OrchestratorSession[] = [
|
export interface OrchestratorSessionsProps {
|
||||||
{
|
jobs?: ActiveJob[] | undefined;
|
||||||
id: "s1",
|
}
|
||||||
orchId: "ORCH-001",
|
|
||||||
name: "infra-refactor",
|
/* ------------------------------------------------------------------ */
|
||||||
badge: "running",
|
/* Mapping helpers */
|
||||||
badgeVariant: "badge-teal",
|
/* ------------------------------------------------------------------ */
|
||||||
duration: "2h 14m",
|
|
||||||
agents: [
|
const STEP_COLORS: string[] = [
|
||||||
{
|
"rgba(47,128,255,0.15)",
|
||||||
id: "a1",
|
"rgba(20,184,166,0.15)",
|
||||||
initials: "PL",
|
"rgba(245,158,11,0.15)",
|
||||||
avatarColor: "rgba(47,128,255,0.15)",
|
"rgba(139,92,246,0.15)",
|
||||||
name: "planner-agent",
|
"rgba(229,72,77,0.15)",
|
||||||
task: "Analyzing network topology",
|
|
||||||
status: "blue",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "a2",
|
|
||||||
initials: "EX",
|
|
||||||
avatarColor: "rgba(20,184,166,0.15)",
|
|
||||||
name: "executor-agent",
|
|
||||||
task: "Applying Terraform modules",
|
|
||||||
status: "teal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "a3",
|
|
||||||
initials: "QA",
|
|
||||||
avatarColor: "rgba(245,158,11,0.15)",
|
|
||||||
name: "reviewer-agent",
|
|
||||||
task: "Waiting for executor output",
|
|
||||||
status: "amber",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "s2",
|
|
||||||
orchId: "ORCH-002",
|
|
||||||
name: "api-v3-migration",
|
|
||||||
badge: "running",
|
|
||||||
badgeVariant: "badge-teal",
|
|
||||||
duration: "45m",
|
|
||||||
agents: [
|
|
||||||
{
|
|
||||||
id: "a4",
|
|
||||||
initials: "MG",
|
|
||||||
avatarColor: "rgba(139,92,246,0.15)",
|
|
||||||
name: "migrator-agent",
|
|
||||||
task: "Rewriting endpoint handlers",
|
|
||||||
status: "blue",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function statusToDotVariant(status: string): DotVariant {
|
||||||
|
const lower = status.toLowerCase();
|
||||||
|
if (lower === "running" || lower === "active" || lower === "completed") return "teal";
|
||||||
|
if (lower === "pending" || lower === "queued") return "blue";
|
||||||
|
if (lower === "waiting" || lower === "paused") return "amber";
|
||||||
|
if (lower === "failed" || lower === "error") return "red";
|
||||||
|
return "muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusToBadgeVariant(status: string): BadgeVariant {
|
||||||
|
const lower = status.toLowerCase();
|
||||||
|
if (lower === "running" || lower === "active") return "badge-teal";
|
||||||
|
if (lower === "pending" || lower === "queued") return "badge-blue";
|
||||||
|
if (lower === "waiting" || lower === "paused") return "badge-amber";
|
||||||
|
if (lower === "failed" || lower === "error") return "badge-red";
|
||||||
|
if (lower === "completed") return "badge-purple";
|
||||||
|
return "badge-muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(isoDate: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const start = new Date(isoDate).getTime();
|
||||||
|
const diffMs = now - start;
|
||||||
|
|
||||||
|
if (Number.isNaN(diffMs) || diffMs < 0) return "0m";
|
||||||
|
|
||||||
|
const totalMinutes = Math.floor(diffMs / 60_000);
|
||||||
|
if (totalMinutes < 60) return `${String(totalMinutes)}m`;
|
||||||
|
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return `${String(hours)}h ${String(minutes)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/[\s\-_]+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w.charAt(0).toUpperCase())
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapJobToSession(job: ActiveJob): OrchestratorSession {
|
||||||
|
const agents: AgentNode[] = job.steps.map((step, idx) => ({
|
||||||
|
id: step.id,
|
||||||
|
initials: initials(step.name),
|
||||||
|
avatarColor: STEP_COLORS[idx % STEP_COLORS.length] ?? "rgba(100,116,139,0.15)",
|
||||||
|
name: step.name,
|
||||||
|
task: `Phase: ${step.phase}`,
|
||||||
|
status: statusToDotVariant(step.status),
|
||||||
|
statusLabel: step.status.toLowerCase(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
orchId: job.id.length > 10 ? job.id.slice(0, 10).toUpperCase() : job.id.toUpperCase(),
|
||||||
|
name: job.type,
|
||||||
|
badge: job.status,
|
||||||
|
badgeVariant: statusToBadgeVariant(job.status),
|
||||||
|
duration: formatDuration(job.createdAt),
|
||||||
|
progress: job.progressPercent,
|
||||||
|
agents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Sub-components */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
interface AgentNodeItemProps {
|
interface AgentNodeItemProps {
|
||||||
agent: AgentNode;
|
agent: AgentNode;
|
||||||
}
|
}
|
||||||
@@ -155,6 +196,16 @@ function AgentNodeItem({ agent }: AgentNodeItemProps): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Dot variant={agent.status} />
|
<Dot variant={agent.status} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.statusLabel}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -169,7 +220,7 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
|||||||
style={{
|
style={{
|
||||||
background: "var(--bg-mid)",
|
background: "var(--bg-mid)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "var(--r-md)",
|
borderRadius: "var(--r)",
|
||||||
padding: "12px 14px",
|
padding: "12px 14px",
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
@@ -182,7 +233,7 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
|||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dot variant="teal" />
|
<Dot variant={statusToDotVariant(session.badge)} />
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--mono)",
|
fontFamily: "var(--mono)",
|
||||||
@@ -214,6 +265,27 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
|||||||
{session.duration}
|
{session.duration}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{session.progress > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: "var(--border)",
|
||||||
|
marginBottom: 10,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${String(session.progress)}%`,
|
||||||
|
background: "var(--primary)",
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: "width 0.3s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
{session.agents.map((agent) => (
|
{session.agents.map((agent) => (
|
||||||
<AgentNodeItem key={agent.id} agent={agent} />
|
<AgentNodeItem key={agent.id} agent={agent} />
|
||||||
@@ -223,18 +295,48 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OrchestratorSessions(): ReactElement {
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main export */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function OrchestratorSessions({ jobs }: OrchestratorSessionsProps): ReactElement {
|
||||||
|
const sessions = jobs ? jobs.map(mapJobToSession) : [];
|
||||||
|
const activeCount = jobs
|
||||||
|
? jobs.filter(
|
||||||
|
(j) => j.status.toLowerCase() === "running" || j.status.toLowerCase() === "active"
|
||||||
|
).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Active Orchestrator Sessions"
|
title="Active Orchestrator Sessions"
|
||||||
subtitle="3 of 8 projects running"
|
subtitle={
|
||||||
actions={<Badge variant="badge-teal">3 active</Badge>}
|
sessions.length > 0
|
||||||
|
? `${String(activeCount)} of ${String(sessions.length)} jobs running`
|
||||||
|
: "No active sessions"
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
sessions.length > 0 ? (
|
||||||
|
<Badge variant="badge-teal">{String(activeCount)} active</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
{sessions.map((session) => (
|
{sessions.length > 0 ? (
|
||||||
<OrchCard key={session.id} session={session} />
|
sessions.map((session) => <OrchCard key={session.id} session={session} />)
|
||||||
))}
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No active sessions
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ interface QuickAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actions: QuickAction[] = [
|
const actions: QuickAction[] = [
|
||||||
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.15)" },
|
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.12)" },
|
||||||
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.15)" },
|
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.12)" },
|
||||||
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.15)" },
|
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.12)" },
|
||||||
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,0.15)" },
|
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,0.12)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ActionButtonProps {
|
interface ActionButtonProps {
|
||||||
@@ -36,24 +36,25 @@ function ActionButton({ action }: ActionButtonProps): ReactElement {
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
|
||||||
gap: 8,
|
gap: 8,
|
||||||
padding: "16px 12px",
|
padding: "10px 12px",
|
||||||
borderRadius: "var(--r-md)",
|
borderRadius: "var(--r)",
|
||||||
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
|
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
|
||||||
background: hovered ? "var(--surface)" : "var(--bg-mid)",
|
background: hovered ? "var(--surface)" : "var(--bg-mid)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
transition: "border-color 0.15s, background 0.15s",
|
transition: "border-color 0.15s, background 0.15s, color 0.15s",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: hovered ? "var(--text)" : "var(--text-2)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
borderRadius: 6,
|
borderRadius: 5,
|
||||||
background: action.iconBg,
|
background: action.iconBg,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -63,15 +64,7 @@ function ActionButton({ action }: ActionButtonProps): ReactElement {
|
|||||||
>
|
>
|
||||||
{action.icon}
|
{action.icon}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span>{action.label}</span>
|
||||||
style={{
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -84,7 +77,7 @@ export function QuickActions(): ReactElement {
|
|||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr",
|
gridTemplateColumns: "1fr 1fr",
|
||||||
gap: 10,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{actions.map((action) => (
|
{actions.map((action) => (
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@mosaic/ui";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ComingSoon } from "@/components/ui/ComingSoon";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if we're in development mode (runtime check for testability)
|
|
||||||
*/
|
|
||||||
function isDevelopment(): boolean {
|
|
||||||
return process.env.NODE_ENV === "development";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal Quick Capture Widget implementation
|
|
||||||
*/
|
|
||||||
function QuickCaptureWidgetInternal(): React.JSX.Element {
|
|
||||||
const [idea, setIdea] = useState("");
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!idea.trim()) return;
|
|
||||||
|
|
||||||
// TODO: Implement quick capture API call
|
|
||||||
// For now, just show a success indicator
|
|
||||||
console.log("Quick capture:", idea);
|
|
||||||
setIdea("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToTasks = (): void => {
|
|
||||||
router.push("/tasks");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Quick Capture</h2>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">Quickly jot down ideas or brain dumps</p>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
|
||||||
<textarea
|
|
||||||
value={idea}
|
|
||||||
onChange={(e) => {
|
|
||||||
setIdea(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="What's on your mind?"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button type="submit" variant="primary" size="sm">
|
|
||||||
Save Note
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={goToTasks}>
|
|
||||||
Create Task
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quick Capture Widget (Dashboard version)
|
|
||||||
*
|
|
||||||
* In production: Shows Coming Soon placeholder
|
|
||||||
* In development: Full widget functionality
|
|
||||||
*/
|
|
||||||
export function QuickCaptureWidget(): React.JSX.Element {
|
|
||||||
// In production, show Coming Soon placeholder
|
|
||||||
if (!isDevelopment()) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<ComingSoon
|
|
||||||
feature="Quick Capture"
|
|
||||||
description="Quickly jot down ideas for later organization. This feature is currently under development."
|
|
||||||
className="!p-0 !min-h-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In development, show full widget functionality
|
|
||||||
return <QuickCaptureWidgetInternal />;
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { Task } from "@mosaic/shared";
|
|
||||||
import { TaskPriority } from "@mosaic/shared";
|
|
||||||
import { formatDate } from "@/lib/utils/date-format";
|
|
||||||
import { TaskStatus } from "@mosaic/shared";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface RecentTasksWidgetProps {
|
|
||||||
tasks: Task[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusIcons: Record<TaskStatus, string> = {
|
|
||||||
[TaskStatus.NOT_STARTED]: "⚪",
|
|
||||||
[TaskStatus.IN_PROGRESS]: "🟢",
|
|
||||||
[TaskStatus.PAUSED]: "⏸️",
|
|
||||||
[TaskStatus.COMPLETED]: "✅",
|
|
||||||
[TaskStatus.ARCHIVED]: "💤",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RecentTasksWidget({ tasks, isLoading }: RecentTasksWidgetProps): React.JSX.Element {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex justify-center items-center">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
|
||||||
<span className="ml-3 text-gray-600">Loading tasks...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentTasks = tasks.slice(0, 5);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Recent Tasks</h2>
|
|
||||||
<Link href="/tasks" className="text-sm text-blue-600 hover:text-blue-700">
|
|
||||||
View all →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{recentTasks.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-500 text-center py-4">No tasks yet</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{recentTasks.map((task) => (
|
|
||||||
<li
|
|
||||||
key={task.id}
|
|
||||||
className="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-lg flex-shrink-0" aria-label={`Status: ${task.status}`}>
|
|
||||||
{statusIcons[task.status]}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-medium text-gray-900 text-sm truncate">{task.title}</h3>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
{task.priority !== TaskPriority.LOW && (
|
|
||||||
<span
|
|
||||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
|
||||||
task.priority === TaskPriority.HIGH
|
|
||||||
? "bg-red-100 text-red-700"
|
|
||||||
: "bg-blue-100 text-blue-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{task.priority}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{task.dueDate && (
|
|
||||||
<span className="text-xs text-gray-500">{formatDate(task.dueDate)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,24 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Card, SectionHeader, ProgressBar, type ProgressBarVariant } from "@mosaic/ui";
|
import { Card, SectionHeader, ProgressBar, type ProgressBarVariant } from "@mosaic/ui";
|
||||||
|
import type { TokenBudgetEntry } from "@/lib/api/dashboard";
|
||||||
|
|
||||||
interface ModelBudget {
|
export interface TokenBudgetProps {
|
||||||
|
budgets?: TokenBudgetEntry[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const VARIANT_CYCLE: ProgressBarVariant[] = ["blue", "teal", "purple", "amber"];
|
||||||
|
|
||||||
|
function formatTokenCount(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelBudgetDisplay {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
usage: string;
|
usage: string;
|
||||||
@@ -9,39 +26,28 @@ interface ModelBudget {
|
|||||||
variant: ProgressBarVariant;
|
variant: ProgressBarVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
const models: ModelBudget[] = [
|
function mapBudgetToDisplay(entry: TokenBudgetEntry, index: number): ModelBudgetDisplay {
|
||||||
{
|
const percent = entry.limit > 0 ? Math.round((entry.used / entry.limit) * 100) : 0;
|
||||||
id: "sonnet",
|
const usage =
|
||||||
label: "claude-3-5-sonnet",
|
entry.limit > 0
|
||||||
usage: "2.1M / 3M",
|
? `${formatTokenCount(entry.used)} / ${formatTokenCount(entry.limit)}`
|
||||||
value: 70,
|
: "unlimited";
|
||||||
variant: "blue",
|
|
||||||
},
|
return {
|
||||||
{
|
id: entry.model,
|
||||||
id: "haiku",
|
label: entry.model,
|
||||||
label: "claude-3-haiku",
|
usage,
|
||||||
usage: "890K / 5M",
|
value: percent,
|
||||||
value: 18,
|
variant: VARIANT_CYCLE[index % VARIANT_CYCLE.length] ?? "blue",
|
||||||
variant: "teal",
|
};
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: "gpt4o",
|
/* ------------------------------------------------------------------ */
|
||||||
label: "gpt-4o",
|
/* Components */
|
||||||
usage: "320K / 1M",
|
/* ------------------------------------------------------------------ */
|
||||||
value: 32,
|
|
||||||
variant: "purple",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "llama",
|
|
||||||
label: "local/llama-3.3",
|
|
||||||
usage: "unlimited",
|
|
||||||
value: 55,
|
|
||||||
variant: "amber",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ModelRowProps {
|
interface ModelRowProps {
|
||||||
model: ModelBudget;
|
model: ModelBudgetDisplay;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelRow({ model }: ModelRowProps): ReactElement {
|
function ModelRow({ model }: ModelRowProps): ReactElement {
|
||||||
@@ -84,14 +90,27 @@ function ModelRow({ model }: ModelRowProps): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TokenBudget(): ReactElement {
|
export function TokenBudget({ budgets }: TokenBudgetProps): ReactElement {
|
||||||
|
const displayModels = budgets ? budgets.map(mapBudgetToDisplay) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<SectionHeader title="Token Budget" subtitle="Usage by model" />
|
<SectionHeader title="Token Budget" subtitle="Usage by model" />
|
||||||
<div>
|
<div>
|
||||||
{models.map((model) => (
|
{displayModels.length > 0 ? (
|
||||||
<ModelRow key={model.id} model={model} />
|
displayModels.map((model) => <ModelRow key={model.id} model={model} />)
|
||||||
))}
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No budget data
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import type { Event } from "@mosaic/shared";
|
|
||||||
import { formatTime, formatDate } from "@/lib/utils/date-format";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface UpcomingEventsWidgetProps {
|
|
||||||
events: Event[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UpcomingEventsWidget({
|
|
||||||
events,
|
|
||||||
isLoading,
|
|
||||||
}: UpcomingEventsWidgetProps): React.JSX.Element {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex justify-center items-center">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
|
||||||
<span className="ml-3 text-gray-600">Loading events...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const upcomingEvents = events.slice(0, 4);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Upcoming Events</h2>
|
|
||||||
<Link href="/calendar" className="text-sm text-blue-600 hover:text-blue-700">
|
|
||||||
View calendar →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{upcomingEvents.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-500 text-center py-4">No upcoming events</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{upcomingEvents.map((event) => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className="flex items-start gap-3 p-3 rounded-lg border-l-4 border-blue-500 bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0 text-center min-w-[3.5rem]">
|
|
||||||
<div className="text-xs text-gray-500 uppercase font-semibold">
|
|
||||||
{formatDate(event.startTime).split(",")[0]}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-gray-900">
|
|
||||||
{formatTime(event.startTime)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-medium text-gray-900 text-sm truncate">{event.title}</h3>
|
|
||||||
{event.location && (
|
|
||||||
<p className="text-xs text-gray-500 mt-0.5">📍 {event.location}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* QuickCaptureWidget (Dashboard) Component Tests
|
|
||||||
* Tests environment-based behavior
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { QuickCaptureWidget } from "../QuickCaptureWidget";
|
|
||||||
|
|
||||||
// Mock next/navigation
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: (): { push: () => void } => ({
|
|
||||||
push: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("QuickCaptureWidget (Dashboard)", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach((): void => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Development mode", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.stubEnv("NODE_ENV", "development");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the widget form in development", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
// Should show the header
|
|
||||||
expect(screen.getByText("Quick Capture")).toBeInTheDocument();
|
|
||||||
// Should show the textarea
|
|
||||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
|
||||||
// Should show the Save Note button
|
|
||||||
expect(screen.getByRole("button", { name: /save note/i })).toBeInTheDocument();
|
|
||||||
// Should show the Create Task button
|
|
||||||
expect(screen.getByRole("button", { name: /create task/i })).toBeInTheDocument();
|
|
||||||
// Should NOT show Coming Soon badge
|
|
||||||
expect(screen.queryByText("Coming Soon")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have a placeholder for the textarea", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
const textarea = screen.getByRole("textbox");
|
|
||||||
expect(textarea).toHaveAttribute("placeholder", "What's on your mind?");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Production mode", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.stubEnv("NODE_ENV", "production");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show Coming Soon placeholder in production", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
// Should show Coming Soon badge
|
|
||||||
expect(screen.getByText("Coming Soon")).toBeInTheDocument();
|
|
||||||
// Should show feature name
|
|
||||||
expect(screen.getByText("Quick Capture")).toBeInTheDocument();
|
|
||||||
// Should NOT show the textarea
|
|
||||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
|
||||||
// Should NOT show the buttons
|
|
||||||
expect(screen.queryByRole("button", { name: /save note/i })).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("button", { name: /create task/i })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show description in Coming Soon placeholder", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/jot down ideas for later organization/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Test mode (non-development)", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.stubEnv("NODE_ENV", "test");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show Coming Soon placeholder in test mode", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
// Test mode is not development, so should show Coming Soon
|
|
||||||
expect(screen.getByText("Coming Soon")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef } from "react";
|
import React from "react";
|
||||||
import { LinkAutocomplete } from "./LinkAutocomplete";
|
import { KnowledgeEditor } from "./KnowledgeEditor";
|
||||||
|
|
||||||
interface EntryEditorProps {
|
interface EntryEditorProps {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -9,57 +9,21 @@ interface EntryEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EntryEditor - Markdown editor with live preview and link autocomplete
|
* EntryEditor - WYSIWYG editor for knowledge entries.
|
||||||
|
* Wraps KnowledgeEditor (Tiptap) with markdown round-trip.
|
||||||
|
* Content is stored as markdown; the editor provides rich text editing.
|
||||||
*/
|
*/
|
||||||
export function EntryEditor({ content, onChange }: EntryEditorProps): React.JSX.Element {
|
export function EntryEditor({ content, onChange }: EntryEditorProps): React.JSX.Element {
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="entry-editor relative">
|
<div className="entry-editor">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<label className="block text-sm font-medium mb-2" style={{ color: "var(--text-2)" }}>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
Content
|
||||||
Content (Markdown)
|
</label>
|
||||||
</label>
|
<KnowledgeEditor
|
||||||
<button
|
content={content}
|
||||||
type="button"
|
onChange={onChange}
|
||||||
onClick={() => {
|
placeholder="Write your content here... Supports markdown formatting."
|
||||||
setShowPreview(!showPreview);
|
/>
|
||||||
}}
|
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
{showPreview ? "Edit" : "Preview"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showPreview ? (
|
|
||||||
<div className="prose prose-sm max-w-none dark:prose-invert p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 min-h-[300px]">
|
|
||||||
<div className="whitespace-pre-wrap">{content}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="relative">
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => {
|
|
||||||
onChange(e.target.value);
|
|
||||||
}}
|
|
||||||
className="w-full min-h-[300px] p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
placeholder="Write your content here... (Markdown supported)"
|
|
||||||
/>
|
|
||||||
<LinkAutocomplete
|
|
||||||
textareaRef={textareaRef}
|
|
||||||
onInsert={(newContent) => {
|
|
||||||
onChange(newContent);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Supports Markdown formatting. Type <code className="text-xs">[[</code> to insert links to
|
|
||||||
other entries.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
||||||
import { EntryCard } from "./EntryCard";
|
import { EntryCard } from "./EntryCard";
|
||||||
import { BookOpen } from "lucide-react";
|
import { BookOpen } from "lucide-react";
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
|
|
||||||
interface EntryListProps {
|
interface EntryListProps {
|
||||||
entries: KnowledgeEntryWithTags[];
|
entries: KnowledgeEntryWithTags[];
|
||||||
@@ -20,18 +21,22 @@ export function EntryList({
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center p-12">
|
<div className="flex justify-center items-center p-12">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
<MosaicSpinner size={36} label="Loading entries..." />
|
||||||
<span className="ml-3 text-gray-600">Loading entries...</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center p-12 bg-white rounded-lg shadow-sm border border-gray-200">
|
<div
|
||||||
<BookOpen className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
className="text-center p-12 rounded-lg border"
|
||||||
<p className="text-lg text-gray-700 font-medium">No entries found</p>
|
style={{ background: "var(--surface)", borderColor: "var(--border)" }}
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
>
|
||||||
|
<BookOpen className="w-12 h-12 mx-auto mb-3" style={{ color: "var(--text-muted)" }} />
|
||||||
|
<p className="text-lg font-medium" style={{ color: "var(--text-muted)" }}>
|
||||||
|
No entries found
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-2" style={{ color: "var(--text-muted)" }}>
|
||||||
Try adjusting your filters or create a new entry
|
Try adjusting your filters or create a new entry
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
245
apps/web/src/components/knowledge/KnowledgeEditor.css
Normal file
245
apps/web/src/components/knowledge/KnowledgeEditor.css
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
/* KnowledgeEditor — Tiptap/ProseMirror styles
|
||||||
|
Uses CSS variables for theme compatibility */
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap {
|
||||||
|
min-height: 300px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder */
|
||||||
|
.knowledge-editor-content .tiptap p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings */
|
||||||
|
.knowledge-editor-content .tiptap h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 1.5em 0 0.5em;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap h2 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 1.3em 0 0.4em;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1.2em 0 0.3em;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap h1:first-child,
|
||||||
|
.knowledge-editor-content .tiptap h2:first-child,
|
||||||
|
.knowledge-editor-content .tiptap h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paragraphs */
|
||||||
|
.knowledge-editor-content .tiptap p {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bold / Italic / Strikethrough */
|
||||||
|
.knowledge-editor-content .tiptap strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap s {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline code */
|
||||||
|
.knowledge-editor-content .tiptap code {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--primary-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
.knowledge-editor-content .tiptap pre {
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap pre code {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.83rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
.knowledge-editor-content .tiptap ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap li {
|
||||||
|
margin: 0.2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap li > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blockquote */
|
||||||
|
.knowledge-editor-content .tiptap blockquote {
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
padding: 4px 16px;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap blockquote p {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Horizontal rule */
|
||||||
|
.knowledge-editor-content .tiptap hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 1.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
.knowledge-editor-content .tiptap a,
|
||||||
|
.knowledge-editor-content .tiptap .knowledge-editor-link {
|
||||||
|
color: var(--primary-l);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.knowledge-editor-content .tiptap table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap th,
|
||||||
|
.knowledge-editor-content .tiptap td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap th {
|
||||||
|
background: var(--surface-2);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap td {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table selected cell highlight */
|
||||||
|
.knowledge-editor-content .tiptap .selectedCell::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--primary);
|
||||||
|
opacity: 0.08;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap th.selectedCell::after,
|
||||||
|
.knowledge-editor-content .tiptap td.selectedCell::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--primary);
|
||||||
|
opacity: 0.08;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table cell relative positioning for selection overlay */
|
||||||
|
.knowledge-editor-content .tiptap th,
|
||||||
|
.knowledge-editor-content .tiptap td {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column resize handle */
|
||||||
|
.knowledge-editor-content .tiptap .column-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: var(--primary);
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-editor-content .tiptap .tableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Syntax highlighting tokens (lowlight/highlight.js) */
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-keyword { color: var(--ms-purple-400); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-string { color: var(--ms-teal-400); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-number { color: var(--ms-amber-400); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-comment { color: var(--muted); font-style: italic; }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-function { color: var(--ms-blue-400); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-title { color: var(--ms-blue-400); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-params { color: var(--text-2); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-built_in { color: var(--ms-cyan-500); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-literal { color: var(--ms-amber-400); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-type { color: var(--ms-teal-400); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-attr { color: var(--ms-purple-400); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-selector-class { color: var(--ms-blue-400); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-selector-tag { color: var(--ms-red-400); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-variable { color: var(--text); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-meta { color: var(--muted); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-tag { color: var(--ms-red-400); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-name { color: var(--ms-red-400); }
|
||||||
|
.knowledge-editor-content .tiptap pre .hljs-attribute { color: var(--ms-purple-400); }
|
||||||
450
apps/web/src/components/knowledge/KnowledgeEditor.tsx
Normal file
450
apps/web/src/components/knowledge/KnowledgeEditor.tsx
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import Link from "@tiptap/extension-link";
|
||||||
|
import { Table } from "@tiptap/extension-table";
|
||||||
|
import { TableRow } from "@tiptap/extension-table-row";
|
||||||
|
import { TableCell } from "@tiptap/extension-table-cell";
|
||||||
|
import { TableHeader } from "@tiptap/extension-table-header";
|
||||||
|
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { Markdown } from "tiptap-markdown";
|
||||||
|
import { common, createLowlight } from "lowlight";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import type { MarkdownStorage } from "tiptap-markdown";
|
||||||
|
|
||||||
|
import "./KnowledgeEditor.css";
|
||||||
|
|
||||||
|
const lowlight = createLowlight(common);
|
||||||
|
|
||||||
|
export interface KnowledgeEditorProps {
|
||||||
|
/** Markdown content for the editor */
|
||||||
|
content: string;
|
||||||
|
/** Called when editor content changes (provides markdown) */
|
||||||
|
onChange: (markdown: string) => void;
|
||||||
|
/** Placeholder text when editor is empty */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Whether the editor is editable */
|
||||||
|
editable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toolbar button helper */
|
||||||
|
function ToolbarButton({
|
||||||
|
onClick,
|
||||||
|
active,
|
||||||
|
disabled,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "var(--r-sm)",
|
||||||
|
border: "none",
|
||||||
|
background: active ? "var(--primary)" : "transparent",
|
||||||
|
color: active ? "#fff" : "var(--text-2)",
|
||||||
|
cursor: disabled ? "default" : "pointer",
|
||||||
|
opacity: disabled ? 0.4 : 1,
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: "all 0.12s ease",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Separator between toolbar groups */
|
||||||
|
function ToolbarSep(): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 1,
|
||||||
|
height: 20,
|
||||||
|
background: "var(--border)",
|
||||||
|
margin: "0 4px",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link insertion handler — prompts for URL */
|
||||||
|
function toggleLink(editor: Editor): void {
|
||||||
|
if (editor.isActive("link")) {
|
||||||
|
editor.chain().focus().unsetLink().run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = window.prompt("Enter URL:");
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SVG icon components for toolbar */
|
||||||
|
function BulletListIcon(): ReactElement {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<circle cx="3" cy="4" r="1.5" />
|
||||||
|
<rect x="6" y="3" width="8" height="2" rx="0.5" />
|
||||||
|
<circle cx="3" cy="8" r="1.5" />
|
||||||
|
<rect x="6" y="7" width="8" height="2" rx="0.5" />
|
||||||
|
<circle cx="3" cy="12" r="1.5" />
|
||||||
|
<rect x="6" y="11" width="8" height="2" rx="0.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderedListIcon(): ReactElement {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<text x="1" y="5.5" fontSize="5" fontWeight="bold">
|
||||||
|
1
|
||||||
|
</text>
|
||||||
|
<rect x="6" y="3" width="8" height="2" rx="0.5" />
|
||||||
|
<text x="1" y="9.5" fontSize="5" fontWeight="bold">
|
||||||
|
2
|
||||||
|
</text>
|
||||||
|
<rect x="6" y="7" width="8" height="2" rx="0.5" />
|
||||||
|
<text x="1" y="13.5" fontSize="5" fontWeight="bold">
|
||||||
|
3
|
||||||
|
</text>
|
||||||
|
<rect x="6" y="11" width="8" height="2" rx="0.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuoteIcon(): ReactElement {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M3 3h2l-1 4h2v6H2V7l1-4zm7 0h2l-1 4h2v6H9V7l1-4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlockIcon(): ReactElement {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
>
|
||||||
|
<polyline points="5,3 1,8 5,13" />
|
||||||
|
<polyline points="11,3 15,8 11,13" />
|
||||||
|
<line x1="9" y1="2" x2="7" y2="14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkIcon(): ReactElement {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
>
|
||||||
|
<path d="M6.5 9.5l3-3" />
|
||||||
|
<path d="M9 6l1.5-1.5a2.12 2.12 0 013 3L12 9" />
|
||||||
|
<path d="M7 10l-1.5 1.5a2.12 2.12 0 01-3-3L4 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableIcon(): ReactElement {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
>
|
||||||
|
<rect x="1" y="2" width="14" height="12" rx="1" />
|
||||||
|
<line x1="1" y1="6" x2="15" y2="6" />
|
||||||
|
<line x1="1" y1="10" x2="15" y2="10" />
|
||||||
|
<line x1="6" y1="2" x2="6" y2="14" />
|
||||||
|
<line x1="11" y1="2" x2="11" y2="14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editor toolbar component */
|
||||||
|
function EditorToolbar({ editor }: { editor: Editor }): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
padding: "6px 8px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
background: "var(--surface-2)",
|
||||||
|
borderRadius: "var(--r-lg) var(--r-lg) 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Headings */}
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
editor.chain().focus().toggleHeading({ level: 1 }).run();
|
||||||
|
}}
|
||||||
|
active={editor.isActive("heading", { level: 1 })}
|
||||||
|
title="Heading 1"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||||
|
}}
|
||||||
|
active={editor.isActive("heading", { level: 2 })}
|
||||||
|
title="Heading 2"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
editor.chain().focus().toggleHeading({ level: 3 }).run();
|
||||||
|
}}
|
||||||
|
active={editor.isActive("heading", { level: 3 })}
|
||||||
|
title="Heading 3"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSep />
|
||||||
|
|
||||||
|
{/* Text formatting */}
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
editor.chain().focus().toggleBold().run();
|
||||||
|
}}
|
||||||
|
active={editor.isActive("bold")}
|
||||||
|
title="Bold (Ctrl+B)"
|
||||||
|
>
|
||||||
|
B
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
editor.chain().focus().toggleItalic().run();
|
||||||
|
}}
|
||||||
|
active={editor.isActive("italic")}
|
||||||
|
title="Italic (Ctrl+I)"
|
||||||
|
>
|
||||||
|
<span style={{ fontStyle: "italic" }}>I</span>
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
editor.chain().focus().toggleStrike().run();
|
||||||
|
}}
|
||||||
|
active={editor.isActive("strike")}
|
||||||
|
title="Strikethrough"
|
||||||
|
>
|
||||||
|
<span style={{ textDecoration: "line-through" }}>S</span>
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
editor.chain().focus().toggleCode().run();
|
||||||
|
}}
|
||||||
|
active={editor.isActive("code")}
|
||||||
|
title="Inline Code"
|
||||||
|
>
|
||||||
|
{"<>"}
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSep />
|
||||||
|
|
||||||
|
{/* Lists */}
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
editor.chain().focus().toggleBulletList().run();
|
||||||
|
}}
|
||||||
|
active={editor.isActive("bulletList")}
|
||||||
|
title="Bullet List"
|
||||||
|
>
|
||||||
|
<BulletListIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
editor.chain().focus().toggleOrderedList().run();
|
||||||
|
}}
|
||||||
|
active={editor.isActive("orderedList")}
|
||||||
|
title="Ordered List"
|
||||||
|
>
|
||||||
|
<OrderedListIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
editor.chain().focus().toggleBlockquote().run();
|
||||||
|
}}
|
||||||
|
active={editor.isActive("blockquote")}
|
||||||
|
title="Blockquote"
|
||||||
|
>
|
||||||
|
<QuoteIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSep />
|
||||||
|
|
||||||
|
{/* Code block */}
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
editor.chain().focus().toggleCodeBlock().run();
|
||||||
|
}}
|
||||||
|
active={editor.isActive("codeBlock")}
|
||||||
|
title="Code Block"
|
||||||
|
>
|
||||||
|
<CodeBlockIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
{/* Link */}
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
toggleLink(editor);
|
||||||
|
}}
|
||||||
|
active={editor.isActive("link")}
|
||||||
|
title="Insert Link"
|
||||||
|
>
|
||||||
|
<LinkIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||||
|
}}
|
||||||
|
disabled={editor.isActive("table")}
|
||||||
|
title="Insert Table"
|
||||||
|
>
|
||||||
|
<TableIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSep />
|
||||||
|
|
||||||
|
{/* Horizontal rule */}
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={(): void => {
|
||||||
|
editor.chain().focus().setHorizontalRule().run();
|
||||||
|
}}
|
||||||
|
title="Horizontal Rule"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</ToolbarButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeEditor({
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Start writing...",
|
||||||
|
editable = true,
|
||||||
|
}: KnowledgeEditorProps): ReactElement {
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
({ editor: e }: { editor: Editor }) => {
|
||||||
|
const s = e.storage as unknown as Record<string, MarkdownStorage>;
|
||||||
|
const mdStorage = s.markdown;
|
||||||
|
if (mdStorage) {
|
||||||
|
onChange(mdStorage.getMarkdown());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
codeBlock: false,
|
||||||
|
}),
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
HTMLAttributes: {
|
||||||
|
rel: "noopener noreferrer",
|
||||||
|
class: "knowledge-editor-link",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
}),
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableHeader,
|
||||||
|
CodeBlockLowlight.configure({
|
||||||
|
lowlight,
|
||||||
|
}),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder,
|
||||||
|
}),
|
||||||
|
Markdown.configure({
|
||||||
|
html: true,
|
||||||
|
breaks: false,
|
||||||
|
tightLists: true,
|
||||||
|
transformPastedText: true,
|
||||||
|
transformCopiedText: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
editable,
|
||||||
|
onUpdate: handleUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- useEditor returns null during SSR/init
|
||||||
|
if (!editor) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: 300,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--muted)", fontSize: "0.85rem" }}>Loading editor...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="knowledge-editor"
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "var(--surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editable && <EditorToolbar editor={editor} />}
|
||||||
|
<EditorContent editor={editor} className="knowledge-editor-content" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,30 @@ import * as knowledgeApi from "@/lib/api/knowledge";
|
|||||||
// Mock the knowledge API
|
// Mock the knowledge API
|
||||||
vi.mock("@/lib/api/knowledge");
|
vi.mock("@/lib/api/knowledge");
|
||||||
|
|
||||||
|
// Mock MosaicSpinner to expose a test ID
|
||||||
|
vi.mock("@/components/ui/MosaicSpinner", () => ({
|
||||||
|
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
|
||||||
|
<div data-testid="loading-spinner">{label ?? "Loading..."}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock elkjs since it requires APIs not available in test environment
|
||||||
|
vi.mock("elkjs/lib/elk.bundled.js", () => ({
|
||||||
|
default: class ELK {
|
||||||
|
layout(graph: {
|
||||||
|
children?: { id: string }[];
|
||||||
|
}): Promise<{ children: { id: string; x: number; y: number }[] }> {
|
||||||
|
return Promise.resolve({
|
||||||
|
children: (graph.children ?? []).map((child: { id: string }, i: number) => ({
|
||||||
|
id: child.id,
|
||||||
|
x: i * 100,
|
||||||
|
y: i * 100,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock Next.js router
|
// Mock Next.js router
|
||||||
const mockPush = vi.fn();
|
const mockPush = vi.fn();
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { fetchKnowledgeGraph } from "@/lib/api/knowledge";
|
import { fetchKnowledgeGraph } from "@/lib/api/knowledge";
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
import ELK from "elkjs/lib/elk.bundled.js";
|
import ELK from "elkjs/lib/elk.bundled.js";
|
||||||
|
|
||||||
// PDA-friendly status colors from CLAUDE.md
|
// PDA-friendly status colors from CLAUDE.md
|
||||||
@@ -376,10 +377,7 @@ export function KnowledgeGraphViewer({
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen">
|
<div className="flex items-center justify-center h-screen">
|
||||||
<div
|
<MosaicSpinner size={48} label="Loading knowledge graph..." />
|
||||||
data-testid="loading-spinner"
|
|
||||||
className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -387,11 +385,14 @@ export function KnowledgeGraphViewer({
|
|||||||
if (error || !graphData) {
|
if (error || !graphData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-screen p-8">
|
<div className="flex flex-col items-center justify-center h-screen p-8">
|
||||||
<div className="text-red-500 text-xl font-semibold mb-2">Error Loading Graph</div>
|
<div className="text-xl font-semibold mb-2" style={{ color: "var(--danger)" }}>
|
||||||
|
Error Loading Graph
|
||||||
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">{error}</div>
|
<div className="text-sm text-gray-500 dark:text-gray-400">{error}</div>
|
||||||
<button
|
<button
|
||||||
onClick={loadGraph}
|
onClick={loadGraph}
|
||||||
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
className="mt-4 px-4 py-2 rounded text-white"
|
||||||
|
style={{ background: "var(--danger)" }}
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchKnowledgeStats } from "@/lib/api/knowledge";
|
import { fetchKnowledgeStats } from "@/lib/api/knowledge";
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
interface KnowledgeStats {
|
interface KnowledgeStats {
|
||||||
@@ -61,13 +62,20 @@ export function StatsDashboard(): React.JSX.Element {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-12">
|
<div className="flex items-center justify-center p-12">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
<MosaicSpinner size={36} label="Loading statistics..." />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !stats) {
|
if (error || !stats) {
|
||||||
return <div className="p-8 text-center text-red-500">Error loading statistics: {error}</div>;
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<p className="font-medium mb-2" style={{ color: "var(--danger)" }}>
|
||||||
|
Error loading statistics
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { overview, mostConnected, recentActivity, tagDistribution } = stats;
|
const { overview, mostConnected, recentActivity, tagDistribution } = stats;
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { EntryEditor } from "../EntryEditor";
|
import { EntryEditor } from "../EntryEditor";
|
||||||
|
|
||||||
// Mock the LinkAutocomplete component
|
// Mock KnowledgeEditor since Tiptap requires a full DOM
|
||||||
vi.mock("../LinkAutocomplete", () => ({
|
vi.mock("../KnowledgeEditor", () => ({
|
||||||
LinkAutocomplete: (): React.JSX.Element => (
|
KnowledgeEditor: ({
|
||||||
<div data-testid="link-autocomplete">LinkAutocomplete</div>
|
content,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
onChange: (md: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}): React.JSX.Element => (
|
||||||
|
<div data-testid="knowledge-editor" data-content={content} data-placeholder={placeholder}>
|
||||||
|
<button
|
||||||
|
data-testid="trigger-change"
|
||||||
|
onClick={(): void => {
|
||||||
|
onChange("updated content");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -21,133 +37,50 @@ describe("EntryEditor", (): void => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render textarea in edit mode by default", (): void => {
|
it("should render KnowledgeEditor component", (): void => {
|
||||||
render(<EntryEditor {...defaultProps} />);
|
render(<EntryEditor {...defaultProps} />);
|
||||||
|
|
||||||
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
expect(screen.getByTestId("knowledge-editor")).toBeInTheDocument();
|
||||||
expect(textarea).toBeInTheDocument();
|
|
||||||
expect(textarea.tagName).toBe("TEXTAREA");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should display current content in textarea", (): void => {
|
it("should have a content label", (): void => {
|
||||||
|
render(<EntryEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass content to KnowledgeEditor", (): void => {
|
||||||
const content = "# Test Content\n\nThis is a test.";
|
const content = "# Test Content\n\nThis is a test.";
|
||||||
render(<EntryEditor {...defaultProps} content={content} />);
|
render(<EntryEditor {...defaultProps} content={content} />);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
const editor = screen.getByTestId("knowledge-editor");
|
||||||
const textarea = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
|
expect(editor).toHaveAttribute("data-content", content);
|
||||||
expect(textarea.value).toBe(content);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should call onChange when content is modified", async (): Promise<void> => {
|
it("should pass placeholder to KnowledgeEditor", (): void => {
|
||||||
|
render(<EntryEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
const editor = screen.getByTestId("knowledge-editor");
|
||||||
|
expect(editor).toHaveAttribute(
|
||||||
|
"data-placeholder",
|
||||||
|
"Write your content here... Supports markdown formatting."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should forward onChange to KnowledgeEditor", async (): Promise<void> => {
|
||||||
|
const { default: userEvent } = await import("@testing-library/user-event");
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onChangeMock = vi.fn();
|
const onChangeMock = vi.fn();
|
||||||
|
|
||||||
render(<EntryEditor {...defaultProps} onChange={onChangeMock} />);
|
render(<EntryEditor {...defaultProps} onChange={onChangeMock} />);
|
||||||
|
|
||||||
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
await user.click(screen.getByTestId("trigger-change"));
|
||||||
await user.type(textarea, "Hello");
|
expect(onChangeMock).toHaveBeenCalledWith("updated content");
|
||||||
|
|
||||||
expect(onChangeMock).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should toggle between edit and preview modes", async (): Promise<void> => {
|
it("should render with entry-editor wrapper class", (): void => {
|
||||||
const user = userEvent.setup();
|
const { container } = render(<EntryEditor {...defaultProps} />);
|
||||||
const content = "# Test\n\nPreview this content.";
|
|
||||||
|
|
||||||
render(<EntryEditor {...defaultProps} content={content} />);
|
expect(container.querySelector(".entry-editor")).toBeInTheDocument();
|
||||||
|
|
||||||
// Initially in edit mode
|
|
||||||
expect(screen.getByPlaceholderText(/Write your content here/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Preview")).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Switch to preview mode
|
|
||||||
const previewButton = screen.getByText("Preview");
|
|
||||||
await user.click(previewButton);
|
|
||||||
|
|
||||||
// Should show preview
|
|
||||||
expect(screen.queryByPlaceholderText(/Write your content here/)).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Edit")).toBeInTheDocument();
|
|
||||||
// Check for partial content (newlines may split text across elements)
|
|
||||||
expect(screen.getByText(/Test/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Preview this content/)).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Switch back to edit mode
|
|
||||||
const editButton = screen.getByText("Edit");
|
|
||||||
await user.click(editButton);
|
|
||||||
|
|
||||||
// Should show textarea again
|
|
||||||
expect(screen.getByPlaceholderText(/Write your content here/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Preview")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render LinkAutocomplete component in edit mode", (): void => {
|
|
||||||
render(<EntryEditor {...defaultProps} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("link-autocomplete")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not render LinkAutocomplete in preview mode", async (): Promise<void> => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
|
|
||||||
render(<EntryEditor {...defaultProps} />);
|
|
||||||
|
|
||||||
// LinkAutocomplete should be present in edit mode
|
|
||||||
expect(screen.getByTestId("link-autocomplete")).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Switch to preview mode
|
|
||||||
const previewButton = screen.getByText("Preview");
|
|
||||||
await user.click(previewButton);
|
|
||||||
|
|
||||||
// LinkAutocomplete should not be in preview mode
|
|
||||||
expect(screen.queryByTestId("link-autocomplete")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show help text about wiki-link syntax", (): void => {
|
|
||||||
render(<EntryEditor {...defaultProps} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Type/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/\[\[/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/to insert links/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain content when toggling between modes", async (): Promise<void> => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const content = "# My Content\n\nThis should persist.";
|
|
||||||
|
|
||||||
render(<EntryEditor {...defaultProps} content={content} />);
|
|
||||||
|
|
||||||
// Verify content in edit mode
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
||||||
const textarea = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
|
|
||||||
expect(textarea.value).toBe(content);
|
|
||||||
|
|
||||||
// Toggle to preview
|
|
||||||
await user.click(screen.getByText("Preview"));
|
|
||||||
// Check for partial content (newlines may split text across elements)
|
|
||||||
expect(screen.getByText(/My Content/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/This should persist/)).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Toggle back to edit
|
|
||||||
await user.click(screen.getByText("Edit"));
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
||||||
const textareaAfter = screen.getByPlaceholderText(
|
|
||||||
/Write your content here/
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
expect(textareaAfter.value).toBe(content);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should apply correct styling classes", (): void => {
|
|
||||||
render(<EntryEditor {...defaultProps} />);
|
|
||||||
|
|
||||||
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
|
||||||
expect(textarea).toHaveClass("font-mono");
|
|
||||||
expect(textarea).toHaveClass("text-sm");
|
|
||||||
expect(textarea).toHaveClass("min-h-[300px]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have label for content field", (): void => {
|
|
||||||
render(<EntryEditor {...defaultProps} />);
|
|
||||||
|
|
||||||
expect(screen.getByText("Content (Markdown)")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { KnowledgeEditor } from "../KnowledgeEditor";
|
||||||
|
|
||||||
|
// Mock Tiptap since it requires a full DOM with contenteditable support
|
||||||
|
vi.mock("@tiptap/react", () => {
|
||||||
|
const EditorContent = ({ editor }: { editor: unknown }): React.JSX.Element => (
|
||||||
|
<div data-testid="editor-content" data-editor={editor ? "ready" : "null"} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
useEditor: (): null => null,
|
||||||
|
EditorContent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock tiptap-markdown
|
||||||
|
vi.mock("tiptap-markdown", () => ({
|
||||||
|
Markdown: {
|
||||||
|
configure: vi.fn().mockReturnValue({}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock lowlight
|
||||||
|
vi.mock("lowlight", () => ({
|
||||||
|
common: {},
|
||||||
|
createLowlight: vi.fn().mockReturnValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock extensions
|
||||||
|
vi.mock("@tiptap/starter-kit", () => ({
|
||||||
|
default: { configure: vi.fn().mockReturnValue({}) },
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-link", () => ({
|
||||||
|
default: { configure: vi.fn().mockReturnValue({}) },
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-table", () => ({
|
||||||
|
Table: { configure: vi.fn().mockReturnValue({}) },
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-table-row", () => ({
|
||||||
|
TableRow: {},
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-table-cell", () => ({
|
||||||
|
TableCell: {},
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-table-header", () => ({
|
||||||
|
TableHeader: {},
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-code-block-lowlight", () => ({
|
||||||
|
default: { configure: vi.fn().mockReturnValue({}) },
|
||||||
|
}));
|
||||||
|
vi.mock("@tiptap/extension-placeholder", () => ({
|
||||||
|
default: { configure: vi.fn().mockReturnValue({}) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("KnowledgeEditor", (): void => {
|
||||||
|
const defaultProps = {
|
||||||
|
content: "",
|
||||||
|
onChange: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render loading state when editor is null", (): void => {
|
||||||
|
render(<KnowledgeEditor {...defaultProps} />);
|
||||||
|
expect(screen.getByText("Loading editor...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render with knowledge-editor class", (): void => {
|
||||||
|
// When editor is null, the loading fallback renders instead
|
||||||
|
const { container } = render(<KnowledgeEditor {...defaultProps} />);
|
||||||
|
expect(container.firstChild).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept optional placeholder prop", (): void => {
|
||||||
|
// Smoke test that it doesn't crash with custom placeholder
|
||||||
|
render(<KnowledgeEditor {...defaultProps} placeholder="Custom placeholder" />);
|
||||||
|
expect(screen.getByText("Loading editor...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept optional editable prop", (): void => {
|
||||||
|
// Smoke test that it doesn't crash when read-only
|
||||||
|
render(<KnowledgeEditor {...defaultProps} editable={false} />);
|
||||||
|
expect(screen.getByText("Loading editor...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -356,7 +356,7 @@ function NavItem({ item, isActive, collapsed }: NavItemProps): React.JSX.Element
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "11px",
|
gap: "11px",
|
||||||
padding: "9px 10px",
|
padding: "9px 10px",
|
||||||
borderRadius: "6px",
|
borderRadius: "var(--r-sm)",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: isActive ? "var(--text)" : "var(--muted)",
|
color: isActive ? "var(--text)" : "var(--muted)",
|
||||||
|
|||||||
368
apps/web/src/components/terminal/AgentTerminal.test.tsx
Normal file
368
apps/web/src/components/terminal/AgentTerminal.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
381
apps/web/src/components/terminal/AgentTerminal.tsx
Normal file
381
apps/web/src/components/terminal/AgentTerminal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
581
apps/web/src/components/terminal/TerminalPanel.test.tsx
Normal file
581
apps/web/src/components/terminal/TerminalPanel.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,80 +1,191 @@
|
|||||||
import type { ReactElement, CSSProperties } from "react";
|
"use client";
|
||||||
|
|
||||||
export interface TerminalLine {
|
/**
|
||||||
type: "prompt" | "command" | "output" | "error" | "warning" | "success";
|
* TerminalPanel
|
||||||
content: string;
|
*
|
||||||
}
|
* 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 {
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
id: string;
|
import type { ReactElement, CSSProperties, KeyboardEvent } from "react";
|
||||||
label: string;
|
import { XTerminal } from "./XTerminal";
|
||||||
}
|
import { AgentTerminal } from "./AgentTerminal";
|
||||||
|
import { useTerminalSessions } from "@/hooks/useTerminalSessions";
|
||||||
|
import { useAgentStream } from "@/hooks/useAgentStream";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Types
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
export interface TerminalPanelProps {
|
export interface TerminalPanelProps {
|
||||||
|
/** Whether the panel is visible */
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
/** Called when the user closes the panel */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
tabs?: TerminalTab[];
|
/** Authentication token for the WebSocket connection */
|
||||||
activeTab?: string;
|
token?: string;
|
||||||
onTabChange?: (id: string) => void;
|
/** Optional CSS class name */
|
||||||
lines?: TerminalLine[];
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTabs: TerminalTab[] = [
|
// ==========================================
|
||||||
{ id: "main", label: "main" },
|
// Component
|
||||||
{ 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)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TerminalPanel({
|
export function TerminalPanel({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
tabs,
|
token = "",
|
||||||
activeTab,
|
|
||||||
onTabChange,
|
|
||||||
lines = [],
|
|
||||||
className = "",
|
className = "",
|
||||||
}: TerminalPanelProps): ReactElement {
|
}: 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 = {
|
const panelStyle: CSSProperties = {
|
||||||
background: "var(--bg-deep)",
|
background: "var(--bg-deep)",
|
||||||
@@ -99,33 +210,40 @@ export function TerminalPanel({
|
|||||||
const tabBarStyle: CSSProperties = {
|
const tabBarStyle: CSSProperties = {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 2,
|
gap: 2,
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionsStyle: CSSProperties = {
|
const actionsStyle: CSSProperties = {
|
||||||
marginLeft: "auto",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 4,
|
gap: 4,
|
||||||
|
alignItems: "center",
|
||||||
};
|
};
|
||||||
|
|
||||||
const bodyStyle: CSSProperties = {
|
const bodyStyle: CSSProperties = {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: "auto",
|
overflow: "hidden",
|
||||||
padding: "10px 16px",
|
display: "flex",
|
||||||
fontFamily: "var(--mono)",
|
flexDirection: "column",
|
||||||
fontSize: "0.78rem",
|
minHeight: 0,
|
||||||
lineHeight: 1.6,
|
position: "relative",
|
||||||
};
|
};
|
||||||
|
|
||||||
const cursorStyle: CSSProperties = {
|
// ==========================================
|
||||||
display: "inline-block",
|
// Agent status dot color
|
||||||
width: 7,
|
// ==========================================
|
||||||
height: 14,
|
|
||||||
background: "var(--success)",
|
const agentDotColor = (status: string): string => {
|
||||||
marginLeft: 2,
|
if (status === "running" || status === "spawning") return "var(--success)";
|
||||||
animation: "ms-terminal-blink 1s step-end infinite",
|
if (status === "error") return "var(--danger)";
|
||||||
verticalAlign: "text-bottom",
|
return "var(--muted)";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Render
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
@@ -138,50 +256,315 @@ export function TerminalPanel({
|
|||||||
<div style={headerStyle}>
|
<div style={headerStyle}>
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
||||||
{resolvedTabs.map((tab) => {
|
{/* ---- Terminal session tabs ---- */}
|
||||||
const isActive = tab.id === resolvedActiveTab;
|
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
|
||||||
|
const tabKey = `terminal:${sessionId}`;
|
||||||
|
const isActive = tabKey === activeTabId;
|
||||||
|
const isEditing = sessionId === editingTabId;
|
||||||
|
|
||||||
const tabStyle: CSSProperties = {
|
const tabStyle: CSSProperties = {
|
||||||
padding: "3px 10px",
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
padding: "3px 6px 3px 10px",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
fontFamily: "var(--mono)",
|
fontFamily: "var(--mono)",
|
||||||
color: isActive ? "var(--success)" : "var(--muted)",
|
color: isActive ? "var(--success)" : "var(--muted)",
|
||||||
cursor: "pointer",
|
|
||||||
background: isActive ? "var(--surface)" : "transparent",
|
background: isActive ? "var(--surface)" : "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
|
flexShrink: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={tabKey} style={tabStyle}>
|
||||||
key={tab.id}
|
{isEditing ? (
|
||||||
role="tab"
|
<input
|
||||||
aria-selected={isActive}
|
ref={editInputRef}
|
||||||
style={tabStyle}
|
value={editingName}
|
||||||
onClick={(): void => {
|
onChange={(e): void => {
|
||||||
onTabChange?.(tab.id);
|
setEditingName(e.target.value);
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e): void => {
|
onBlur={commitRename}
|
||||||
if (!isActive) {
|
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
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
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 => {
|
||||||
|
setActiveTabId(tabKey);
|
||||||
|
setActiveSession(sessionId);
|
||||||
|
}}
|
||||||
|
onDoubleClick={(): void => {
|
||||||
|
handleTabDoubleClick(sessionId, sessionInfo.name);
|
||||||
|
}}
|
||||||
|
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={sessionInfo.name}
|
||||||
|
>
|
||||||
|
{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.background = "var(--surface)";
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
}
|
}}
|
||||||
}}
|
onMouseLeave={(e): void => {
|
||||||
onMouseLeave={(e): void => {
|
|
||||||
if (!isActive) {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
(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}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{/* Status dot */}
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div style={actionsStyle}>
|
<div style={actionsStyle}>
|
||||||
|
{/* Close panel button */}
|
||||||
<button
|
<button
|
||||||
aria-label="Close terminal"
|
aria-label="Close terminal"
|
||||||
style={{
|
style={{
|
||||||
@@ -208,7 +591,7 @@ export function TerminalPanel({
|
|||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
(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">
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M1 1L11 11M11 1L1 11"
|
d="M1 1L11 11M11 1L1 11"
|
||||||
@@ -221,34 +604,90 @@ export function TerminalPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Connection error banner */}
|
||||||
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
|
{connectionError !== null && (
|
||||||
{lines.map((line, index) => {
|
<div
|
||||||
const isLast = index === lines.length - 1;
|
role="alert"
|
||||||
const lineStyle: CSSProperties = {
|
style={{
|
||||||
display: "flex",
|
padding: "4px 16px",
|
||||||
gap: 8,
|
fontSize: "0.75rem",
|
||||||
};
|
fontFamily: "var(--mono)",
|
||||||
const contentStyle: CSSProperties = {
|
color: "var(--danger)",
|
||||||
color: getLineColor(line.type),
|
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 (
|
return (
|
||||||
<div key={index} style={lineStyle}>
|
<div key={tabKey} style={termStyle}>
|
||||||
<span style={contentStyle}>
|
<XTerminal
|
||||||
{line.content}
|
sessionId={sessionId}
|
||||||
{isLast && <span aria-hidden="true" style={cursorStyle} />}
|
sendInput={sendInput}
|
||||||
</span>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Show cursor even when no lines */}
|
{/* ---- Agent session panels ---- */}
|
||||||
{lines.length === 0 && (
|
{[...agents.entries()].map(([agentId, agentSession]) => {
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
const tabKey = `agent:${agentId}`;
|
||||||
<span style={{ color: "var(--success)" }}>
|
const isActive = tabKey === activeTabId;
|
||||||
<span aria-hidden="true" style={cursorStyle} />
|
const agentPanelStyle: CSSProperties = {
|
||||||
</span>
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
270
apps/web/src/components/terminal/XTerminal.test.tsx
Normal file
270
apps/web/src/components/terminal/XTerminal.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
404
apps/web/src/components/terminal/XTerminal.tsx
Normal file
404
apps/web/src/components/terminal/XTerminal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
export type { TerminalLine, TerminalTab, TerminalPanelProps } from "./TerminalPanel";
|
export type { TerminalPanelProps } from "./TerminalPanel";
|
||||||
export { TerminalPanel } from "./TerminalPanel";
|
export { TerminalPanel } from "./TerminalPanel";
|
||||||
|
export type { XTerminalProps } from "./XTerminal";
|
||||||
|
export { XTerminal } from "./XTerminal";
|
||||||
|
export type { AgentTerminalProps } from "./AgentTerminal";
|
||||||
|
export { AgentTerminal } from "./AgentTerminal";
|
||||||
|
|||||||
@@ -37,16 +37,31 @@ export function BaseWidget({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-widget-id={id}
|
data-widget-id={id}
|
||||||
className={cn(
|
className={cn("flex flex-col h-full overflow-hidden", className)}
|
||||||
"flex flex-col h-full bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden",
|
style={{
|
||||||
className
|
background: "var(--surface)",
|
||||||
)}
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-sm)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Widget Header */}
|
{/* Widget Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50">
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3"
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
background: "var(--surface-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
|
<h3 className="text-sm font-semibold truncate" style={{ color: "var(--text)" }}>
|
||||||
{description && <p className="text-xs text-gray-500 truncate mt-0.5">{description}</p>}
|
{title}
|
||||||
|
</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs truncate mt-0.5" style={{ color: "var(--muted)" }}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Control buttons - only show if handlers provided */}
|
{/* Control buttons - only show if handlers provided */}
|
||||||
@@ -56,7 +71,8 @@ export function BaseWidget({
|
|||||||
<button
|
<button
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
aria-label="Edit widget"
|
aria-label="Edit widget"
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
className="p-1 rounded transition-colors"
|
||||||
|
style={{ color: "var(--muted)" }}
|
||||||
title="Edit widget"
|
title="Edit widget"
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
@@ -66,7 +82,8 @@ export function BaseWidget({
|
|||||||
<button
|
<button
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
aria-label="Remove widget"
|
aria-label="Remove widget"
|
||||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
className="p-1 rounded transition-colors"
|
||||||
|
style={{ color: "var(--muted)" }}
|
||||||
title="Remove widget"
|
title="Remove widget"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -81,15 +98,24 @@ export function BaseWidget({
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
<div
|
||||||
<span className="text-sm text-gray-500">Loading...</span>
|
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
|
||||||
|
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm" style={{ color: "var(--muted)" }}>
|
||||||
|
Loading...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-red-500 text-sm font-medium mb-1">Error</div>
|
<div className="text-sm font-medium mb-1" style={{ color: "var(--danger)" }}>
|
||||||
<div className="text-xs text-gray-600">{error}</div>
|
Error
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: "var(--muted)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
183
apps/web/src/components/widgets/WidgetConfigDialog.tsx
Normal file
183
apps/web/src/components/widgets/WidgetConfigDialog.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* WidgetConfigDialog — Per-widget settings dialog.
|
||||||
|
*
|
||||||
|
* Reads configSchema from the widget definition. When the schema is empty
|
||||||
|
* (current state for all 7 widgets), shows a placeholder message.
|
||||||
|
* As widgets gain configSchema definitions, this dialog will render
|
||||||
|
* appropriate form controls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { getWidgetByName } from "./WidgetRegistry";
|
||||||
|
|
||||||
|
export interface WidgetConfigDialogProps {
|
||||||
|
widgetId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetConfigDialog({
|
||||||
|
widgetId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: WidgetConfigDialogProps): ReactElement | null {
|
||||||
|
const [hoverClose, setHoverClose] = useState(false);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
// Extract widget type from ID (format: "WidgetType-suffix")
|
||||||
|
const widgetType = widgetId.split("-")[0] ?? "";
|
||||||
|
const widgetDef = getWidgetByName(widgetType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.4)",
|
||||||
|
zIndex: 999,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dialog */}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Widget Settings"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: 420,
|
||||||
|
maxWidth: "90vw",
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
zIndex: 1000,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "16px 20px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--text)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{widgetDef?.displayName ?? "Widget"} Settings
|
||||||
|
</h2>
|
||||||
|
{widgetDef?.description && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: "4px 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{widgetDef.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 4,
|
||||||
|
fontSize: "1.2rem",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ padding: "20px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 16px",
|
||||||
|
textAlign: "center",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
background: "var(--surface-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No configuration options available for this widget yet.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: "8px 0 0",
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Widget configuration will be added in a future update.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
padding: "12px 20px",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
onMouseEnter={(): void => {
|
||||||
|
setHoverClose(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(): void => {
|
||||||
|
setHoverClose(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "6px 16px",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: hoverClose ? "var(--surface-2)" : "transparent",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 0.12s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||||
import GridLayout from "react-grid-layout";
|
import GridLayout from "react-grid-layout";
|
||||||
import type { Layout, LayoutItem } from "react-grid-layout";
|
import type { Layout, LayoutItem } from "react-grid-layout";
|
||||||
import type { WidgetPlacement } from "@mosaic/shared";
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
@@ -22,6 +22,7 @@ export interface WidgetGridProps {
|
|||||||
layout: WidgetPlacement[];
|
layout: WidgetPlacement[];
|
||||||
onLayoutChange: (layout: WidgetPlacement[]) => void;
|
onLayoutChange: (layout: WidgetPlacement[]) => void;
|
||||||
onRemoveWidget?: (widgetId: string) => void;
|
onRemoveWidget?: (widgetId: string) => void;
|
||||||
|
onEditWidget?: (widgetId: string) => void;
|
||||||
isEditing?: boolean;
|
isEditing?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -30,9 +31,34 @@ export function WidgetGrid({
|
|||||||
layout,
|
layout,
|
||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
onRemoveWidget,
|
onRemoveWidget,
|
||||||
|
onEditWidget,
|
||||||
isEditing = false,
|
isEditing = false,
|
||||||
className,
|
className,
|
||||||
}: WidgetGridProps): React.JSX.Element {
|
}: WidgetGridProps): React.JSX.Element {
|
||||||
|
// Measure container width for responsive grid
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(1200);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries): void => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (entry) {
|
||||||
|
setContainerWidth(entry.contentRect.width);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
|
||||||
|
// Set initial width
|
||||||
|
setContainerWidth(el.clientWidth);
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Convert WidgetPlacement to react-grid-layout Layout format
|
// Convert WidgetPlacement to react-grid-layout Layout format
|
||||||
const gridLayout: Layout = useMemo(
|
const gridLayout: Layout = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -96,22 +122,34 @@ export function WidgetGrid({
|
|||||||
// Empty state
|
// Empty state
|
||||||
if (layout.length === 0) {
|
if (layout.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full min-h-[400px] bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex items-center justify-center h-full min-h-[400px]"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface-2)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
border: "2px dashed var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-gray-500 text-lg font-medium">No widgets yet</p>
|
<p className="text-lg font-medium" style={{ color: "var(--muted)" }}>
|
||||||
<p className="text-gray-400 text-sm mt-1">Add widgets to customize your dashboard</p>
|
No widgets yet
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--muted)", opacity: 0.7 }}>
|
||||||
|
Add widgets to customize your dashboard
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("widget-grid-container", className)}>
|
<div ref={containerRef} className={cn("widget-grid-container", className)}>
|
||||||
<GridLayout
|
<GridLayout
|
||||||
className="layout"
|
className="layout"
|
||||||
layout={gridLayout}
|
layout={gridLayout}
|
||||||
onLayoutChange={handleLayoutChange}
|
onLayoutChange={handleLayoutChange}
|
||||||
width={1200}
|
width={containerWidth}
|
||||||
gridConfig={{
|
gridConfig={{
|
||||||
cols: 12,
|
cols: 12,
|
||||||
rowHeight: 100,
|
rowHeight: 100,
|
||||||
@@ -147,6 +185,12 @@ export function WidgetGrid({
|
|||||||
id={item.i}
|
id={item.i}
|
||||||
title={widgetDef.displayName}
|
title={widgetDef.displayName}
|
||||||
description={widgetDef.description}
|
description={widgetDef.description}
|
||||||
|
{...(isEditing &&
|
||||||
|
onEditWidget && {
|
||||||
|
onEdit: (): void => {
|
||||||
|
onEditWidget(item.i);
|
||||||
|
},
|
||||||
|
})}
|
||||||
{...(isEditing &&
|
{...(isEditing &&
|
||||||
onRemoveWidget && {
|
onRemoveWidget && {
|
||||||
onRemove: (): void => {
|
onRemove: (): void => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user