Compare commits
41 Commits
7cc5538721
...
v0.20.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d2c51eda91 | |||
| 78b643a945 | |||
| f93503ebcf | |||
| c0e679ab7c | |||
| 6ac63fe755 | |||
| 1667f28d71 | |||
| 66fe475fa1 | |||
| d39ab6aafc | |||
| 147e8ac574 | |||
| c38bfae16c | |||
| 36b4d8323d | |||
| 833662a64f | |||
| b3922e1d5b | |||
| 78b71a0ecc | |||
| dd0568cf15 | |||
| 8964226163 | |||
| 11f22a7e96 | |||
| edcff6a0e0 | |||
| e3cba37e8c | |||
| 21bf7e050f | |||
| 83d5aee53a | |||
| cc5b108b2f | |||
| bf299bb672 | |||
| ad99cb9a03 | |||
| d05b870f08 | |||
| 1aaf5618ce | |||
| 9b2520ce1f | |||
| b110c469c4 | |||
| 859dcfc4b7 | |||
| 13aa52aa53 | |||
| 417c6ab49c | |||
| 8128eb7fbe | |||
| 7de0e734b0 | |||
| 6290fc3d53 | |||
| 9f4de1682f | |||
| 374ca7ace3 | |||
| 72c64d2eeb | |||
| 5f6c520a98 | |||
| 9a7673bea2 | |||
| 91934b9933 | |||
| 7f89682946 |
30
.env.example
30
.env.example
@@ -79,7 +79,7 @@ OIDC_CLIENT_ID=your-client-id-here
|
||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||
# Redirect URI must match what's configured in Authentik
|
||||
# Development: http://localhost:3001/auth/oauth2/callback/authentik
|
||||
# Production: https://api.mosaicstack.dev/auth/oauth2/callback/authentik
|
||||
# Production: https://mosaic-api.woltje.com/auth/oauth2/callback/authentik
|
||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||
|
||||
# Authentik PostgreSQL Database
|
||||
@@ -314,17 +314,19 @@ COORDINATOR_ENABLED=true
|
||||
# TTL is in seconds, limits are per TTL window
|
||||
|
||||
# Global rate limit (applies to all endpoints unless overridden)
|
||||
RATE_LIMIT_TTL=60 # Time window in seconds
|
||||
RATE_LIMIT_GLOBAL_LIMIT=100 # Requests per window
|
||||
# Time window in seconds
|
||||
RATE_LIMIT_TTL=60
|
||||
# Requests per window
|
||||
RATE_LIMIT_GLOBAL_LIMIT=100
|
||||
|
||||
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch)
|
||||
RATE_LIMIT_WEBHOOK_LIMIT=60 # Requests per minute
|
||||
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch) — requests per minute
|
||||
RATE_LIMIT_WEBHOOK_LIMIT=60
|
||||
|
||||
# Coordinator endpoints (/coordinator/*)
|
||||
RATE_LIMIT_COORDINATOR_LIMIT=100 # Requests per minute
|
||||
# Coordinator endpoints (/coordinator/*) — requests per minute
|
||||
RATE_LIMIT_COORDINATOR_LIMIT=100
|
||||
|
||||
# Health check endpoints (/coordinator/health)
|
||||
RATE_LIMIT_HEALTH_LIMIT=300 # Requests per minute (higher for monitoring)
|
||||
# Health check endpoints (/coordinator/health) — requests per minute (higher for monitoring)
|
||||
RATE_LIMIT_HEALTH_LIMIT=300
|
||||
|
||||
# Storage backend for rate limiting (redis or memory)
|
||||
# redis: Uses Valkey for distributed rate limiting (recommended for production)
|
||||
@@ -359,17 +361,17 @@ RATE_LIMIT_STORAGE=redis
|
||||
# a single workspace.
|
||||
MATRIX_HOMESERVER_URL=http://synapse:8008
|
||||
MATRIX_ACCESS_TOKEN=
|
||||
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.example.com
|
||||
MATRIX_SERVER_NAME=matrix.example.com
|
||||
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.example.com
|
||||
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.woltje.com
|
||||
MATRIX_SERVER_NAME=matrix.woltje.com
|
||||
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.woltje.com
|
||||
# MATRIX_WORKSPACE_ID=your-workspace-uuid
|
||||
|
||||
# ======================
|
||||
# Matrix / Synapse Deployment
|
||||
# ======================
|
||||
# Domains for Traefik routing to Matrix services
|
||||
MATRIX_DOMAIN=matrix.example.com
|
||||
ELEMENT_DOMAIN=chat.example.com
|
||||
MATRIX_DOMAIN=matrix.woltje.com
|
||||
ELEMENT_DOMAIN=chat.woltje.com
|
||||
|
||||
# Synapse database (created automatically by synapse-db-init in the swarm compose)
|
||||
SYNAPSE_POSTGRES_DB=synapse
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"status": "active",
|
||||
"task_prefix": "",
|
||||
"quality_gates": "",
|
||||
"milestone_version": "0.0.1",
|
||||
"milestone_version": "0.0.20",
|
||||
"milestones": [],
|
||||
"sessions": []
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ variables:
|
||||
pnpm install --frozen-lockfile
|
||||
- &use_deps |
|
||||
corepack enable
|
||||
- &turbo_env
|
||||
TURBO_API:
|
||||
from_secret: turbo_api
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
TURBO_TEAM:
|
||||
from_secret: turbo_team
|
||||
- &kaniko_setup |
|
||||
mkdir -p /kaniko/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||
@@ -52,17 +59,6 @@ steps:
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/api" lint
|
||||
depends_on:
|
||||
- prisma-generate
|
||||
- build-shared
|
||||
|
||||
prisma-generate:
|
||||
image: *node_image
|
||||
environment:
|
||||
@@ -73,26 +69,27 @@ steps:
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
build-shared:
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/shared" build
|
||||
- pnpm turbo lint --filter=@mosaic/api
|
||||
depends_on:
|
||||
- install
|
||||
- prisma-generate
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/api" typecheck
|
||||
- pnpm turbo typecheck --filter=@mosaic/api
|
||||
depends_on:
|
||||
- prisma-generate
|
||||
- build-shared
|
||||
|
||||
prisma-migrate:
|
||||
image: *node_image
|
||||
@@ -124,6 +121,7 @@ steps:
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
NODE_ENV: "production"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm turbo build --filter=@mosaic/api
|
||||
|
||||
@@ -24,6 +24,13 @@ variables:
|
||||
pnpm install --frozen-lockfile
|
||||
- &use_deps |
|
||||
corepack enable
|
||||
- &turbo_env
|
||||
TURBO_API:
|
||||
from_secret: turbo_api
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
TURBO_TEAM:
|
||||
from_secret: turbo_team
|
||||
- &kaniko_setup |
|
||||
mkdir -p /kaniko/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||
@@ -48,9 +55,10 @@ steps:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/orchestrator" lint
|
||||
- pnpm turbo lint --filter=@mosaic/orchestrator
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
@@ -58,9 +66,10 @@ steps:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/orchestrator" typecheck
|
||||
- pnpm turbo typecheck --filter=@mosaic/orchestrator
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
@@ -68,9 +77,10 @@ steps:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/orchestrator" test
|
||||
- pnpm turbo test --filter=@mosaic/orchestrator
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
@@ -81,6 +91,7 @@ steps:
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
NODE_ENV: "production"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm turbo build --filter=@mosaic/orchestrator
|
||||
|
||||
@@ -24,6 +24,13 @@ variables:
|
||||
pnpm install --frozen-lockfile
|
||||
- &use_deps |
|
||||
corepack enable
|
||||
- &turbo_env
|
||||
TURBO_API:
|
||||
from_secret: turbo_api
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
TURBO_TEAM:
|
||||
from_secret: turbo_team
|
||||
- &kaniko_setup |
|
||||
mkdir -p /kaniko/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||
@@ -44,46 +51,38 @@ steps:
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
build-shared:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/shared" build
|
||||
- pnpm --filter "@mosaic/ui" build
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/web" lint
|
||||
- pnpm turbo lint --filter=@mosaic/web
|
||||
depends_on:
|
||||
- build-shared
|
||||
- install
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/web" typecheck
|
||||
- pnpm turbo typecheck --filter=@mosaic/web
|
||||
depends_on:
|
||||
- build-shared
|
||||
- install
|
||||
|
||||
test:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/web" test
|
||||
- pnpm turbo test --filter=@mosaic/web
|
||||
depends_on:
|
||||
- build-shared
|
||||
- install
|
||||
|
||||
# === Build ===
|
||||
|
||||
@@ -92,6 +91,7 @@ steps:
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
NODE_ENV: "production"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm turbo build --filter=@mosaic/web
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -46,6 +46,21 @@ pnpm lint
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Versioning Protocol (HARD GATE)
|
||||
|
||||
**This project is ALPHA. All versions MUST be `0.0.x`.**
|
||||
|
||||
- The `0.1.0` release is FORBIDDEN until Jason explicitly authorizes it.
|
||||
- Every milestone bump increments the patch: `0.0.20` → `0.0.21` → `0.0.22`, etc.
|
||||
- ALL package.json files in the monorepo MUST stay in sync at the same version.
|
||||
- Use `scripts/version-bump.sh <version>` to bump — it enforces the alpha constraint and updates all packages atomically.
|
||||
- The script rejects any version >= `0.1.0`.
|
||||
- When creating a release tag, the tag MUST match the package version: `v0.0.x`.
|
||||
|
||||
**Milestone-to-version mapping** is defined in the PRD (`docs/PRD.md`) under "Delivery/Milestone Intent". Agents MUST use the version from that table when tagging a milestone release.
|
||||
|
||||
**Violation of this protocol is a blocking error.** If an agent attempts to set a version >= `0.1.0`, stop and escalate.
|
||||
|
||||
## Standards and Quality
|
||||
|
||||
- Enforce strict typing and no unsafe shortcuts.
|
||||
|
||||
@@ -18,6 +18,12 @@ COPY turbo.json ./
|
||||
# ======================
|
||||
FROM base AS deps
|
||||
|
||||
# Install build tools for native addons (node-pty requires node-gyp compilation)
|
||||
# and OpenSSL for Prisma engine detection
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy all package.json files for workspace resolution
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
@@ -25,7 +31,11 @@ COPY packages/config/package.json ./packages/config/
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
|
||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
|
||||
# scripts or fail to find prebuilt binaries for this Node.js version
|
||||
RUN pnpm install --frozen-lockfile \
|
||||
&& cd node_modules/.pnpm/node-pty@*/node_modules/node-pty \
|
||||
&& npx node-gyp rebuild 2>&1 || true
|
||||
|
||||
# ======================
|
||||
# Builder stage
|
||||
@@ -58,7 +68,11 @@ FROM node:24-slim AS production
|
||||
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
|
||||
|
||||
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||
# - openssl: Prisma engine detection requires libssl
|
||||
# - No build tools needed here — native addons are compiled in the deps stage
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||
&& chmod 755 /usr/local/bin/dumb-init \
|
||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/api",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
@@ -66,6 +66,7 @@
|
||||
"marked-gfm-heading-id": "^4.1.3",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"matrix-bot-sdk": "^0.8.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"ollama": "^0.6.3",
|
||||
"openai": "^6.17.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TerminalSessionStatus" AS ENUM ('ACTIVE', 'CLOSED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "terminal_sessions" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL DEFAULT 'Terminal',
|
||||
"status" "TerminalSessionStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"closed_at" TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT "terminal_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "terminal_sessions_workspace_id_idx" ON "terminal_sessions"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "terminal_sessions_workspace_id_status_idx" ON "terminal_sessions"("workspace_id", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "terminal_sessions" ADD CONSTRAINT "terminal_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable: add tone and formality_level columns to personalities
|
||||
ALTER TABLE "personalities" ADD COLUMN "tone" TEXT NOT NULL DEFAULT 'neutral';
|
||||
ALTER TABLE "personalities" ADD COLUMN "formality_level" "FormalityLevel" NOT NULL DEFAULT 'NEUTRAL';
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
previewFeatures = ["postgresqlExtensions"]
|
||||
}
|
||||
|
||||
@@ -206,6 +207,11 @@ enum CredentialScope {
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
enum TerminalSessionStatus {
|
||||
ACTIVE
|
||||
CLOSED
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODELS
|
||||
// ============================================
|
||||
@@ -297,6 +303,7 @@ model Workspace {
|
||||
federationEventSubscriptions FederationEventSubscription[]
|
||||
llmUsageLogs LlmUsageLog[]
|
||||
userCredentials UserCredential[]
|
||||
terminalSessions TerminalSession[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@map("workspaces")
|
||||
@@ -1061,6 +1068,10 @@ model Personality {
|
||||
displayName String @map("display_name")
|
||||
description String? @db.Text
|
||||
|
||||
// Tone and formality
|
||||
tone String @default("neutral")
|
||||
formalityLevel FormalityLevel @default(NEUTRAL) @map("formality_level")
|
||||
|
||||
// System prompt
|
||||
systemPrompt String @map("system_prompt") @db.Text
|
||||
|
||||
@@ -1507,3 +1518,23 @@ model LlmUsageLog {
|
||||
@@index([conversationId])
|
||||
@@map("llm_usage_logs")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TERMINAL MODULE
|
||||
// ============================================
|
||||
|
||||
model TerminalSession {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
name String @default("Terminal")
|
||||
status TerminalSessionStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
closedAt DateTime? @map("closed_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([workspaceId])
|
||||
@@index([workspaceId, status])
|
||||
@@map("terminal_sessions")
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ import { CredentialsModule } from "./credentials/credentials.module";
|
||||
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
||||
import { SpeechModule } from "./speech/speech.module";
|
||||
import { DashboardModule } from "./dashboard/dashboard.module";
|
||||
import { TerminalModule } from "./terminal/terminal.module";
|
||||
import { PersonalitiesModule } from "./personalities/personalities.module";
|
||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||
|
||||
@Module({
|
||||
@@ -103,6 +105,8 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
||||
MosaicTelemetryModule,
|
||||
SpeechModule,
|
||||
DashboardModule,
|
||||
TerminalModule,
|
||||
PersonalitiesModule,
|
||||
],
|
||||
controllers: [AppController, CsrfController],
|
||||
providers: [
|
||||
|
||||
@@ -254,6 +254,10 @@ export function createAuth(prisma: PrismaClient) {
|
||||
enabled: true,
|
||||
},
|
||||
plugins: [...getOidcPlugins()],
|
||||
logger: {
|
||||
disabled: false,
|
||||
level: "error",
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max
|
||||
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request
|
||||
|
||||
@@ -123,6 +123,14 @@ export class AuthController {
|
||||
|
||||
try {
|
||||
await handler(req, res);
|
||||
|
||||
// BetterAuth writes responses directly — catch silent 500s that bypass NestJS error handling
|
||||
if (res.statusCode >= 500) {
|
||||
this.logger.error(
|
||||
`BetterAuth returned ${String(res.statusCode)} for ${req.method} ${req.url} from ${clientIp}` +
|
||||
` — check container stdout for '# SERVER_ERROR' details`
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
@@ -16,7 +16,7 @@ interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
@Controller("api/v1/csrf")
|
||||
@Controller("v1/csrf")
|
||||
export class CsrfController {
|
||||
constructor(private readonly csrfService: CsrfService) {}
|
||||
|
||||
|
||||
@@ -174,17 +174,19 @@ describe("CsrfGuard", () => {
|
||||
});
|
||||
|
||||
describe("Session binding validation", () => {
|
||||
it("should reject when user is not authenticated", () => {
|
||||
it("should allow when user context is not yet available (global guard ordering)", () => {
|
||||
// CsrfGuard runs as APP_GUARD before per-controller AuthGuard,
|
||||
// so request.user may not be populated. Double-submit cookie match
|
||||
// is sufficient protection in this case.
|
||||
const token = generateValidToken("user-123");
|
||||
const context = createContext(
|
||||
"POST",
|
||||
{ "csrf-token": token },
|
||||
{ "x-csrf-token": token },
|
||||
false
|
||||
// No userId - unauthenticated
|
||||
// No userId - AuthGuard hasn't run yet
|
||||
);
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
expect(() => guard.canActivate(context)).toThrow("CSRF validation requires authentication");
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject token from different session", () => {
|
||||
|
||||
@@ -89,30 +89,30 @@ export class CsrfGuard implements CanActivate {
|
||||
throw new ForbiddenException("CSRF token mismatch");
|
||||
}
|
||||
|
||||
// Validate session binding via HMAC
|
||||
// Validate session binding via HMAC when user context is available.
|
||||
// CsrfGuard is a global guard (APP_GUARD) that runs before per-controller
|
||||
// AuthGuard, so request.user may not be populated yet. In that case, the
|
||||
// double-submit cookie match above is sufficient CSRF protection.
|
||||
const userId = request.user?.id;
|
||||
if (!userId) {
|
||||
this.logger.warn({
|
||||
event: "CSRF_NO_USER_CONTEXT",
|
||||
if (userId) {
|
||||
if (!this.csrfService.validateToken(cookieToken, userId)) {
|
||||
this.logger.warn({
|
||||
event: "CSRF_SESSION_BINDING_INVALID",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
securityEvent: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw new ForbiddenException("CSRF token not bound to session");
|
||||
}
|
||||
} else {
|
||||
this.logger.debug({
|
||||
event: "CSRF_SKIP_SESSION_BINDING",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
securityEvent: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: "User context not yet available (global guard runs before AuthGuard)",
|
||||
});
|
||||
|
||||
throw new ForbiddenException("CSRF validation requires authentication");
|
||||
}
|
||||
|
||||
if (!this.csrfService.validateToken(cookieToken, userId)) {
|
||||
this.logger.warn({
|
||||
event: "CSRF_SESSION_BINDING_INVALID",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
securityEvent: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw new ForbiddenException("CSRF token not bound to session");
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -110,10 +110,10 @@ export class WorkspaceGuard implements CanActivate {
|
||||
return paramWorkspaceId;
|
||||
}
|
||||
|
||||
// 3. Check request body
|
||||
const bodyWorkspaceId = request.body.workspaceId;
|
||||
if (typeof bodyWorkspaceId === "string") {
|
||||
return bodyWorkspaceId;
|
||||
// 3. Check request body (body may be undefined for GET requests despite Express typings)
|
||||
const body = request.body as Record<string, unknown> | undefined;
|
||||
if (body && typeof body.workspaceId === "string") {
|
||||
return body.workspaceId;
|
||||
}
|
||||
|
||||
// 4. Check query string (backward compatibility for existing clients)
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import type { CommandMessageDetails, CommandResponse } from "./types/message.types";
|
||||
import type { FederationMessageStatus } from "@prisma/client";
|
||||
|
||||
@Controller("api/v1/federation")
|
||||
@Controller("v1/federation")
|
||||
export class CommandController {
|
||||
private readonly logger = new Logger(CommandController.name);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
IncomingEventAckDto,
|
||||
} from "./dto/event.dto";
|
||||
|
||||
@Controller("api/v1/federation")
|
||||
@Controller("v1/federation")
|
||||
export class EventController {
|
||||
private readonly logger = new Logger(EventController.name);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
ValidateFederatedTokenDto,
|
||||
} from "./dto/federated-auth.dto";
|
||||
|
||||
@Controller("api/v1/federation/auth")
|
||||
@Controller("v1/federation/auth")
|
||||
export class FederationAuthController {
|
||||
private readonly logger = new Logger(FederationAuthController.name);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "./dto/connection.dto";
|
||||
import { FederationConnectionStatus } from "@prisma/client";
|
||||
|
||||
@Controller("api/v1/federation")
|
||||
@Controller("v1/federation")
|
||||
export class FederationController {
|
||||
private readonly logger = new Logger(FederationController.name);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import type { QueryMessageDetails, QueryResponse } from "./types/message.types";
|
||||
import type { FederationMessageStatus } from "@prisma/client";
|
||||
|
||||
@Controller("api/v1/federation")
|
||||
@Controller("v1/federation")
|
||||
export class QueryController {
|
||||
private readonly logger = new Logger(QueryController.name);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from "class-validator";
|
||||
import { IsOptional, IsEnum, IsString, IsInt, IsIn, Min, Max } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { EntryStatus } from "@prisma/client";
|
||||
import { EntryStatus, Visibility } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* DTO for querying knowledge entries (list endpoint)
|
||||
@@ -10,10 +10,28 @@ export class EntryQueryDto {
|
||||
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
||||
status?: EntryStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(Visibility, { message: "visibility must be a valid Visibility" })
|
||||
visibility?: Visibility;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "tag must be a string" })
|
||||
tag?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "search must be a string" })
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["updatedAt", "createdAt", "title"], {
|
||||
message: "sortBy must be updatedAt, createdAt, or title",
|
||||
})
|
||||
sortBy?: "updatedAt" | "createdAt" | "title";
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["asc", "desc"], { message: "sortOrder must be asc or desc" })
|
||||
sortOrder?: "asc" | "desc";
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: "page must be an integer" })
|
||||
|
||||
@@ -48,6 +48,10 @@ export class KnowledgeService {
|
||||
where.status = query.status;
|
||||
}
|
||||
|
||||
if (query.visibility) {
|
||||
where.visibility = query.visibility;
|
||||
}
|
||||
|
||||
if (query.tag) {
|
||||
where.tags = {
|
||||
some: {
|
||||
@@ -58,6 +62,20 @@ export class KnowledgeService {
|
||||
};
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ title: { contains: query.search, mode: "insensitive" } },
|
||||
{ content: { contains: query.search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
// Build orderBy
|
||||
const sortField = query.sortBy ?? "updatedAt";
|
||||
const sortDirection = query.sortOrder ?? "desc";
|
||||
const orderBy: Prisma.KnowledgeEntryOrderByWithRelationInput = {
|
||||
[sortField]: sortDirection,
|
||||
};
|
||||
|
||||
// Get total count
|
||||
const total = await this.prisma.knowledgeEntry.count({ where });
|
||||
|
||||
@@ -71,9 +89,7 @@ export class KnowledgeService {
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
orderBy,
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { RequestMethod, ValidationPipe } from "@nestjs/common";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { AppModule } from "./app.module";
|
||||
import { getTrustedOrigins } from "./auth/auth.config";
|
||||
@@ -47,6 +47,16 @@ async function bootstrap() {
|
||||
|
||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||
|
||||
// Set global API prefix — all routes get /api/* except auth and health
|
||||
// Auth routes are excluded because BetterAuth expects /auth/* paths
|
||||
// Health is excluded because Docker healthchecks hit /health directly
|
||||
app.setGlobalPrefix("api", {
|
||||
exclude: [
|
||||
{ path: "health", method: RequestMethod.GET },
|
||||
{ path: "auth/(.*)", method: RequestMethod.ALL },
|
||||
],
|
||||
});
|
||||
|
||||
// Configure CORS for cookie-based authentication
|
||||
// Origin list is shared with BetterAuth trustedOrigins via getTrustedOrigins()
|
||||
const trustedOrigins = getTrustedOrigins();
|
||||
|
||||
@@ -1,59 +1,38 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsInt,
|
||||
IsUUID,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
} from "class-validator";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for creating a new personality/assistant configuration
|
||||
* DTO for creating a new personality
|
||||
* Field names match the frontend API contract from @mosaic/shared Personality type.
|
||||
*/
|
||||
export class CreatePersonalityDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name!: string; // unique identifier slug
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
displayName!: string; // human-readable name
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
@IsString({ message: "description must be a string" })
|
||||
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
systemPrompt!: string;
|
||||
@IsString({ message: "tone must be a string" })
|
||||
@MinLength(1, { message: "tone must not be empty" })
|
||||
@MaxLength(100, { message: "tone must not exceed 100 characters" })
|
||||
tone!: string;
|
||||
|
||||
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
|
||||
formalityLevel!: FormalityLevel;
|
||||
|
||||
@IsString({ message: "systemPromptTemplate must be a string" })
|
||||
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
|
||||
systemPromptTemplate!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(2)
|
||||
temperature?: number; // null = use provider default
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxTokens?: number; // null = use provider default
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID("4")
|
||||
llmProviderInstanceId?: string; // FK to LlmProviderInstance
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@IsBoolean({ message: "isDefault must be a boolean" })
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./create-personality.dto";
|
||||
export * from "./update-personality.dto";
|
||||
export * from "./personality-query.dto";
|
||||
|
||||
12
apps/api/src/personalities/dto/personality-query.dto.ts
Normal file
12
apps/api/src/personalities/dto/personality-query.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsBoolean, IsOptional } from "class-validator";
|
||||
import { Transform } from "class-transformer";
|
||||
|
||||
/**
|
||||
* DTO for querying/filtering personalities
|
||||
*/
|
||||
export class PersonalityQueryDto {
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
@Transform(({ value }) => value === "true" || value === true)
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -1,62 +1,42 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsInt,
|
||||
IsUUID,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
} from "class-validator";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for updating an existing personality/assistant configuration
|
||||
* DTO for updating an existing personality
|
||||
* All fields are optional; only provided fields are updated.
|
||||
*/
|
||||
export class UpdatePersonalityDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name?: string; // unique identifier slug
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
displayName?: string; // human-readable name
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
@IsString({ message: "description must be a string" })
|
||||
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
systemPrompt?: string;
|
||||
@IsString({ message: "tone must be a string" })
|
||||
@MinLength(1, { message: "tone must not be empty" })
|
||||
@MaxLength(100, { message: "tone must not exceed 100 characters" })
|
||||
tone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(2)
|
||||
temperature?: number; // null = use provider default
|
||||
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
|
||||
formalityLevel?: FormalityLevel;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxTokens?: number; // null = use provider default
|
||||
@IsString({ message: "systemPromptTemplate must be a string" })
|
||||
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
|
||||
systemPromptTemplate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID("4")
|
||||
llmProviderInstanceId?: string; // FK to LlmProviderInstance
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@IsBoolean({ message: "isDefault must be a boolean" })
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import type { Personality as PrismaPersonality } from "@prisma/client";
|
||||
import type { FormalityLevel } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Personality entity representing an assistant configuration
|
||||
* Personality response entity
|
||||
* Maps Prisma Personality fields to the frontend API contract.
|
||||
*
|
||||
* Field mapping (Prisma -> API):
|
||||
* systemPrompt -> systemPromptTemplate
|
||||
* isEnabled -> isActive
|
||||
* (tone, formalityLevel are identical in both)
|
||||
*/
|
||||
export class Personality implements PrismaPersonality {
|
||||
id!: string;
|
||||
workspaceId!: string;
|
||||
name!: string; // unique identifier slug
|
||||
displayName!: string; // human-readable name
|
||||
description!: string | null;
|
||||
systemPrompt!: string;
|
||||
temperature!: number | null; // null = use provider default
|
||||
maxTokens!: number | null; // null = use provider default
|
||||
llmProviderInstanceId!: string | null; // FK to LlmProviderInstance
|
||||
isDefault!: boolean;
|
||||
isEnabled!: boolean;
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
export interface PersonalityResponse {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
tone: string;
|
||||
formalityLevel: FormalityLevel;
|
||||
systemPromptTemplate: string;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@@ -2,36 +2,32 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PersonalitiesController } from "./personalities.controller";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
|
||||
describe("PersonalitiesController", () => {
|
||||
let controller: PersonalitiesController;
|
||||
let service: PersonalitiesService;
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockUserId = "user-123";
|
||||
const mockPersonalityId = "personality-123";
|
||||
|
||||
/** API response shape (frontend field names) */
|
||||
const mockPersonality = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
displayName: "Professional Assistant",
|
||||
description: "A professional communication assistant",
|
||||
systemPrompt: "You are a professional assistant who helps with tasks.",
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
llmProviderInstanceId: "provider-123",
|
||||
tone: "professional",
|
||||
formalityLevel: FormalityLevel.FORMAL,
|
||||
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
|
||||
isDefault: true,
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockRequest = {
|
||||
user: { id: mockUserId },
|
||||
workspaceId: mockWorkspaceId,
|
||||
isActive: true,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
};
|
||||
|
||||
const mockPersonalitiesService = {
|
||||
@@ -57,46 +53,43 @@ describe("PersonalitiesController", () => {
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(WorkspaceGuard)
|
||||
.useValue({
|
||||
canActivate: (ctx: {
|
||||
switchToHttp: () => { getRequest: () => { workspaceId: string } };
|
||||
}) => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
req.workspaceId = mockWorkspaceId;
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.overrideGuard(PermissionGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<PersonalitiesController>(PersonalitiesController);
|
||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return all personalities", async () => {
|
||||
const mockPersonalities = [mockPersonality];
|
||||
mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities);
|
||||
it("should return success response with personalities list", async () => {
|
||||
const mockList = [mockPersonality];
|
||||
mockPersonalitiesService.findAll.mockResolvedValue(mockList);
|
||||
|
||||
const result = await controller.findAll(mockRequest);
|
||||
const result = await controller.findAll(mockWorkspaceId, {});
|
||||
|
||||
expect(result).toEqual(mockPersonalities);
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId);
|
||||
expect(result).toEqual({ success: true, data: mockList });
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
||||
it("should pass isActive query filter to service", async () => {
|
||||
mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]);
|
||||
|
||||
const result = await controller.findOne(mockRequest, mockPersonalityId);
|
||||
await controller.findAll(mockWorkspaceId, { isActive: true });
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByName", () => {
|
||||
it("should return a personality by name", async () => {
|
||||
mockPersonalitiesService.findByName.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findByName(mockRequest, "professional-assistant");
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findByName).toHaveBeenCalledWith(mockWorkspaceId, "professional-assistant");
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, { isActive: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,32 +97,40 @@ describe("PersonalitiesController", () => {
|
||||
it("should return the default personality", async () => {
|
||||
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findDefault(mockRequest);
|
||||
const result = await controller.findDefault(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findOne(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a new personality", async () => {
|
||||
const createDto: CreatePersonalityDto = {
|
||||
name: "casual-helper",
|
||||
displayName: "Casual Helper",
|
||||
description: "A casual helper",
|
||||
systemPrompt: "You are a casual assistant.",
|
||||
temperature: 0.8,
|
||||
maxTokens: 1500,
|
||||
tone: "casual",
|
||||
formalityLevel: FormalityLevel.CASUAL,
|
||||
systemPromptTemplate: "You are a casual assistant.",
|
||||
};
|
||||
|
||||
mockPersonalitiesService.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDto,
|
||||
});
|
||||
const created = { ...mockPersonality, ...createDto, isActive: true, isDefault: false };
|
||||
mockPersonalitiesService.create.mockResolvedValue(created);
|
||||
|
||||
const result = await controller.create(mockRequest, createDto);
|
||||
const result = await controller.create(mockWorkspaceId, createDto);
|
||||
|
||||
expect(result).toMatchObject(createDto);
|
||||
expect(result).toMatchObject({ name: createDto.name, tone: createDto.tone });
|
||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
|
||||
});
|
||||
});
|
||||
@@ -138,15 +139,13 @@ describe("PersonalitiesController", () => {
|
||||
it("should update a personality", async () => {
|
||||
const updateDto: UpdatePersonalityDto = {
|
||||
description: "Updated description",
|
||||
temperature: 0.9,
|
||||
tone: "enthusiastic",
|
||||
};
|
||||
|
||||
mockPersonalitiesService.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...updateDto,
|
||||
});
|
||||
const updated = { ...mockPersonality, ...updateDto };
|
||||
mockPersonalitiesService.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await controller.update(mockRequest, mockPersonalityId, updateDto);
|
||||
const result = await controller.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
|
||||
expect(result).toMatchObject(updateDto);
|
||||
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
@@ -157,7 +156,7 @@ describe("PersonalitiesController", () => {
|
||||
it("should delete a personality", async () => {
|
||||
mockPersonalitiesService.delete.mockResolvedValue(undefined);
|
||||
|
||||
await controller.delete(mockRequest, mockPersonalityId);
|
||||
await controller.delete(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
@@ -165,12 +164,10 @@ describe("PersonalitiesController", () => {
|
||||
|
||||
describe("setDefault", () => {
|
||||
it("should set a personality as default", async () => {
|
||||
mockPersonalitiesService.setDefault.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
isDefault: true,
|
||||
});
|
||||
const updated = { ...mockPersonality, isDefault: true };
|
||||
mockPersonalitiesService.setDefault.mockResolvedValue(updated);
|
||||
|
||||
const result = await controller.setDefault(mockRequest, mockPersonalityId);
|
||||
const result = await controller.setDefault(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toMatchObject({ isDefault: true });
|
||||
expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
@@ -6,105 +6,122 @@ import {
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import { Personality } from "./entities/personality.entity";
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: { id: string };
|
||||
workspaceId: string;
|
||||
}
|
||||
import { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import { PersonalityQueryDto } from "./dto/personality-query.dto";
|
||||
import type { PersonalityResponse } from "./entities/personality.entity";
|
||||
|
||||
/**
|
||||
* Controller for managing personality/assistant configurations
|
||||
* Controller for personality CRUD endpoints.
|
||||
* Route: /api/personalities
|
||||
*
|
||||
* Guards applied in order:
|
||||
* 1. AuthGuard - verifies the user is authenticated
|
||||
* 2. WorkspaceGuard - validates workspace access
|
||||
* 3. PermissionGuard - checks role-based permissions
|
||||
*/
|
||||
@Controller("personality")
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller("personalities")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class PersonalitiesController {
|
||||
constructor(private readonly personalitiesService: PersonalitiesService) {}
|
||||
|
||||
/**
|
||||
* List all personalities for the workspace
|
||||
* GET /api/personalities
|
||||
* List all personalities for the workspace.
|
||||
* Supports ?isActive=true|false filter.
|
||||
*/
|
||||
@Get()
|
||||
async findAll(@Req() req: AuthenticatedRequest): Promise<Personality[]> {
|
||||
return this.personalitiesService.findAll(req.workspaceId);
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findAll(
|
||||
@Workspace() workspaceId: string,
|
||||
@Query() query: PersonalityQueryDto
|
||||
): Promise<{ success: true; data: PersonalityResponse[] }> {
|
||||
const data = await this.personalitiesService.findAll(workspaceId, query);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default personality for the workspace
|
||||
* GET /api/personalities/default
|
||||
* Get the default personality for the workspace.
|
||||
* Must be declared before :id to avoid route conflicts.
|
||||
*/
|
||||
@Get("default")
|
||||
async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> {
|
||||
return this.personalitiesService.findDefault(req.workspaceId);
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findDefault(@Workspace() workspaceId: string): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.findDefault(workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a personality by its unique name
|
||||
*/
|
||||
@Get("by-name/:name")
|
||||
async findByName(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("name") name: string
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.findByName(req.workspaceId, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a personality by ID
|
||||
* GET /api/personalities/:id
|
||||
* Get a single personality by ID.
|
||||
*/
|
||||
@Get(":id")
|
||||
async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
|
||||
return this.personalitiesService.findOne(req.workspaceId, id);
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findOne(
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("id") id: string
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.findOne(workspaceId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personality
|
||||
* POST /api/personalities
|
||||
* Create a new personality.
|
||||
*/
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Workspace() workspaceId: string,
|
||||
@Body() dto: CreatePersonalityDto
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.create(req.workspaceId, dto);
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.create(workspaceId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a personality
|
||||
* PATCH /api/personalities/:id
|
||||
* Update an existing personality.
|
||||
*/
|
||||
@Patch(":id")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async update(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdatePersonalityDto
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.update(req.workspaceId, id, dto);
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.update(workspaceId, id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a personality
|
||||
* DELETE /api/personalities/:id
|
||||
* Delete a personality.
|
||||
*/
|
||||
@Delete(":id")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<void> {
|
||||
return this.personalitiesService.delete(req.workspaceId, id);
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async delete(@Workspace() workspaceId: string, @Param("id") id: string): Promise<void> {
|
||||
return this.personalitiesService.delete(workspaceId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a personality as the default
|
||||
* POST /api/personalities/:id/set-default
|
||||
* Convenience endpoint to set a personality as the default.
|
||||
*/
|
||||
@Post(":id/set-default")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async setDefault(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("id") id: string
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.setDefault(req.workspaceId, id);
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.setDefault(workspaceId, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import { NotFoundException, ConflictException } from "@nestjs/common";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
|
||||
describe("PersonalitiesService", () => {
|
||||
let service: PersonalitiesService;
|
||||
@@ -11,22 +13,39 @@ describe("PersonalitiesService", () => {
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockPersonalityId = "personality-123";
|
||||
const mockProviderId = "provider-123";
|
||||
|
||||
const mockPersonality = {
|
||||
/** Raw Prisma record shape (uses Prisma field names) */
|
||||
const mockPrismaRecord = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
displayName: "Professional Assistant",
|
||||
description: "A professional communication assistant",
|
||||
tone: "professional",
|
||||
formalityLevel: FormalityLevel.FORMAL,
|
||||
systemPrompt: "You are a professional assistant who helps with tasks.",
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
llmProviderInstanceId: mockProviderId,
|
||||
llmProviderInstanceId: "provider-123",
|
||||
isDefault: true,
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
};
|
||||
|
||||
/** Expected API response shape (uses frontend field names) */
|
||||
const mockResponse = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
description: "A professional communication assistant",
|
||||
tone: "professional",
|
||||
formalityLevel: FormalityLevel.FORMAL,
|
||||
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
@@ -37,9 +56,7 @@ describe("PersonalitiesService", () => {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn((callback) => callback(mockPrismaService)),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -56,44 +73,54 @@ describe("PersonalitiesService", () => {
|
||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
const createDto: CreatePersonalityDto = {
|
||||
name: "casual-helper",
|
||||
displayName: "Casual Helper",
|
||||
description: "A casual communication helper",
|
||||
systemPrompt: "You are a casual assistant.",
|
||||
temperature: 0.8,
|
||||
maxTokens: 1500,
|
||||
llmProviderInstanceId: mockProviderId,
|
||||
tone: "casual",
|
||||
formalityLevel: FormalityLevel.CASUAL,
|
||||
systemPromptTemplate: "You are a casual assistant.",
|
||||
isDefault: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
it("should create a new personality", async () => {
|
||||
const createdRecord = {
|
||||
...mockPrismaRecord,
|
||||
name: createDto.name,
|
||||
description: createDto.description,
|
||||
tone: createDto.tone,
|
||||
formalityLevel: createDto.formalityLevel,
|
||||
systemPrompt: createDto.systemPromptTemplate,
|
||||
isDefault: false,
|
||||
isEnabled: true,
|
||||
id: "new-personality-id",
|
||||
};
|
||||
|
||||
it("should create a new personality and return API response shape", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.personality.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDto,
|
||||
id: "new-personality-id",
|
||||
isDefault: false,
|
||||
isEnabled: true,
|
||||
});
|
||||
mockPrismaService.personality.create.mockResolvedValue(createdRecord);
|
||||
|
||||
const result = await service.create(mockWorkspaceId, createDto);
|
||||
|
||||
expect(result).toMatchObject(createDto);
|
||||
expect(result.name).toBe(createDto.name);
|
||||
expect(result.tone).toBe(createDto.tone);
|
||||
expect(result.formalityLevel).toBe(createDto.formalityLevel);
|
||||
expect(result.systemPromptTemplate).toBe(createDto.systemPromptTemplate);
|
||||
expect(result.isActive).toBe(true);
|
||||
expect(result.isDefault).toBe(false);
|
||||
|
||||
expect(prisma.personality.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: createDto.name,
|
||||
displayName: createDto.displayName,
|
||||
displayName: createDto.name,
|
||||
description: createDto.description ?? null,
|
||||
systemPrompt: createDto.systemPrompt,
|
||||
temperature: createDto.temperature ?? null,
|
||||
maxTokens: createDto.maxTokens ?? null,
|
||||
llmProviderInstanceId: createDto.llmProviderInstanceId ?? null,
|
||||
tone: createDto.tone,
|
||||
formalityLevel: createDto.formalityLevel,
|
||||
systemPrompt: createDto.systemPromptTemplate,
|
||||
isDefault: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
@@ -101,68 +128,73 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
it("should throw ConflictException when name already exists", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it("should unset other defaults when creating a new default personality", async () => {
|
||||
const createDefaultDto = { ...createDto, isDefault: true };
|
||||
// First call to findFirst checks for name conflict (should be null)
|
||||
// Second call to findFirst finds the existing default personality
|
||||
const createDefaultDto: CreatePersonalityDto = { ...createDto, isDefault: true };
|
||||
const otherDefault = { ...mockPrismaRecord, id: "other-id" };
|
||||
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(null) // No name conflict
|
||||
.mockResolvedValueOnce(mockPersonality); // Existing default
|
||||
mockPrismaService.personality.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
isDefault: false,
|
||||
});
|
||||
.mockResolvedValueOnce(null) // name conflict check
|
||||
.mockResolvedValueOnce(otherDefault); // existing default lookup
|
||||
mockPrismaService.personality.update.mockResolvedValue({ ...otherDefault, isDefault: false });
|
||||
mockPrismaService.personality.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDefaultDto,
|
||||
...createdRecord,
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
await service.create(mockWorkspaceId, createDefaultDto);
|
||||
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
where: { id: "other-id" },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return all personalities for a workspace", async () => {
|
||||
const mockPersonalities = [mockPersonality];
|
||||
mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
|
||||
it("should return mapped response list for a workspace", async () => {
|
||||
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
|
||||
|
||||
const result = await service.findAll(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonalities);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(mockResponse);
|
||||
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by isActive when provided", async () => {
|
||||
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
|
||||
|
||||
await service.findAll(mockWorkspaceId, { isActive: true });
|
||||
|
||||
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId, isEnabled: true },
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
it("should return a mapped personality response by id", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(prisma.personality.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId, workspaceId: mockWorkspaceId },
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
@@ -171,17 +203,14 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
describe("findByName", () => {
|
||||
it("should return a personality by name", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
it("should return a mapped personality response by name", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
const result = await service.findByName(mockWorkspaceId, "professional-assistant");
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
},
|
||||
where: { workspaceId: mockWorkspaceId, name: "professional-assistant" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,11 +225,11 @@ describe("PersonalitiesService", () => {
|
||||
|
||||
describe("findDefault", () => {
|
||||
it("should return the default personality", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
const result = await service.findDefault(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
|
||||
});
|
||||
@@ -216,41 +245,45 @@ describe("PersonalitiesService", () => {
|
||||
describe("update", () => {
|
||||
const updateDto: UpdatePersonalityDto = {
|
||||
description: "Updated description",
|
||||
temperature: 0.9,
|
||||
tone: "formal",
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
it("should update a personality", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.personality.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...updateDto,
|
||||
});
|
||||
it("should update a personality and return mapped response", async () => {
|
||||
const updatedRecord = {
|
||||
...mockPrismaRecord,
|
||||
description: updateDto.description,
|
||||
tone: updateDto.tone,
|
||||
isEnabled: false,
|
||||
};
|
||||
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(null); // name conflict check (no dto.name here)
|
||||
mockPrismaService.personality.update.mockResolvedValue(updatedRecord);
|
||||
|
||||
const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
|
||||
expect(result).toMatchObject(updateDto);
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
data: updateDto,
|
||||
});
|
||||
expect(result.description).toBe(updateDto.description);
|
||||
expect(result.tone).toBe(updateDto.tone);
|
||||
expect(result.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ConflictException when updating to existing name", async () => {
|
||||
const updateNameDto = { name: "existing-name" };
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
id: "different-id",
|
||||
});
|
||||
it("should throw ConflictException when updating to an existing name", async () => {
|
||||
const updateNameDto: UpdatePersonalityDto = { name: "existing-name" };
|
||||
const conflictRecord = { ...mockPrismaRecord, id: "different-id" };
|
||||
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(conflictRecord); // name conflict
|
||||
|
||||
await expect(
|
||||
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
|
||||
@@ -258,14 +291,16 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
it("should unset other defaults when setting as default", async () => {
|
||||
const updateDefaultDto = { isDefault: true };
|
||||
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
|
||||
const updateDefaultDto: UpdatePersonalityDto = { isDefault: true };
|
||||
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
|
||||
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
|
||||
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
|
||||
mockPrismaService.personality.update
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
|
||||
.mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
|
||||
.mockResolvedValueOnce(updatedRecord);
|
||||
|
||||
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
|
||||
|
||||
@@ -273,16 +308,12 @@ describe("PersonalitiesService", () => {
|
||||
where: { id: "other-id" },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
|
||||
where: { id: mockPersonalityId },
|
||||
data: updateDefaultDto,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should delete a personality", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
mockPrismaService.personality.delete.mockResolvedValue(undefined);
|
||||
|
||||
await service.delete(mockWorkspaceId, mockPersonalityId);
|
||||
@@ -293,7 +324,7 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
@@ -303,30 +334,27 @@ describe("PersonalitiesService", () => {
|
||||
|
||||
describe("setDefault", () => {
|
||||
it("should set a personality as default", async () => {
|
||||
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
|
||||
const updatedPersonality = { ...mockPersonality, isDefault: true };
|
||||
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
|
||||
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
|
||||
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality);
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
|
||||
mockPrismaService.personality.update
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
|
||||
.mockResolvedValueOnce(updatedPersonality); // Set new default
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
|
||||
.mockResolvedValueOnce(updatedRecord);
|
||||
|
||||
const result = await service.setDefault(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toMatchObject({ isDefault: true });
|
||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(1, {
|
||||
where: { id: "other-id" },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
|
||||
expect(result.isDefault).toBe(true);
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
|
||||
import type { FormalityLevel, Personality } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import { Personality } from "./entities/personality.entity";
|
||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import type { PersonalityQueryDto } from "./dto/personality-query.dto";
|
||||
import type { PersonalityResponse } from "./entities/personality.entity";
|
||||
|
||||
/**
|
||||
* Service for managing personality/assistant configurations
|
||||
* Service for managing personality/assistant configurations.
|
||||
*
|
||||
* Field mapping:
|
||||
* Prisma `systemPrompt` <-> API/frontend `systemPromptTemplate`
|
||||
* Prisma `isEnabled` <-> API/frontend `isActive`
|
||||
*/
|
||||
@Injectable()
|
||||
export class PersonalitiesService {
|
||||
@@ -12,11 +19,30 @@ export class PersonalitiesService {
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Map a Prisma Personality record to the API response shape.
|
||||
*/
|
||||
private toResponse(personality: Personality): PersonalityResponse {
|
||||
return {
|
||||
id: personality.id,
|
||||
workspaceId: personality.workspaceId,
|
||||
name: personality.name,
|
||||
description: personality.description,
|
||||
tone: personality.tone,
|
||||
formalityLevel: personality.formalityLevel,
|
||||
systemPromptTemplate: personality.systemPrompt,
|
||||
isDefault: personality.isDefault,
|
||||
isActive: personality.isEnabled,
|
||||
createdAt: personality.createdAt,
|
||||
updatedAt: personality.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personality
|
||||
*/
|
||||
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<Personality> {
|
||||
// Check for duplicate name
|
||||
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<PersonalityResponse> {
|
||||
// Check for duplicate name within workspace
|
||||
const existing = await this.prisma.personality.findFirst({
|
||||
where: { workspaceId, name: dto.name },
|
||||
});
|
||||
@@ -25,7 +51,7 @@ export class PersonalitiesService {
|
||||
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
|
||||
}
|
||||
|
||||
// If creating a default personality, unset other defaults
|
||||
// If creating as default, unset other defaults first
|
||||
if (dto.isDefault) {
|
||||
await this.unsetOtherDefaults(workspaceId);
|
||||
}
|
||||
@@ -34,36 +60,43 @@ export class PersonalitiesService {
|
||||
data: {
|
||||
workspaceId,
|
||||
name: dto.name,
|
||||
displayName: dto.displayName,
|
||||
displayName: dto.name, // use name as displayName since frontend doesn't send displayName separately
|
||||
description: dto.description ?? null,
|
||||
systemPrompt: dto.systemPrompt,
|
||||
temperature: dto.temperature ?? null,
|
||||
maxTokens: dto.maxTokens ?? null,
|
||||
llmProviderInstanceId: dto.llmProviderInstanceId ?? null,
|
||||
tone: dto.tone,
|
||||
formalityLevel: dto.formalityLevel,
|
||||
systemPrompt: dto.systemPromptTemplate,
|
||||
isDefault: dto.isDefault ?? false,
|
||||
isEnabled: dto.isEnabled ?? true,
|
||||
isEnabled: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all personalities for a workspace
|
||||
* Find all personalities for a workspace with optional active filter
|
||||
*/
|
||||
async findAll(workspaceId: string): Promise<Personality[]> {
|
||||
return this.prisma.personality.findMany({
|
||||
where: { workspaceId },
|
||||
async findAll(workspaceId: string, query?: PersonalityQueryDto): Promise<PersonalityResponse[]> {
|
||||
const where: { workspaceId: string; isEnabled?: boolean } = { workspaceId };
|
||||
|
||||
if (query?.isActive !== undefined) {
|
||||
where.isEnabled = query.isActive;
|
||||
}
|
||||
|
||||
const personalities = await this.prisma.personality.findMany({
|
||||
where,
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
return personalities.map((p) => this.toResponse(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific personality by ID
|
||||
*/
|
||||
async findOne(workspaceId: string, id: string): Promise<Personality> {
|
||||
const personality = await this.prisma.personality.findUnique({
|
||||
async findOne(workspaceId: string, id: string): Promise<PersonalityResponse> {
|
||||
const personality = await this.prisma.personality.findFirst({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
@@ -71,13 +104,13 @@ export class PersonalitiesService {
|
||||
throw new NotFoundException(`Personality with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a personality by name
|
||||
* Find a personality by name slug
|
||||
*/
|
||||
async findByName(workspaceId: string, name: string): Promise<Personality> {
|
||||
async findByName(workspaceId: string, name: string): Promise<PersonalityResponse> {
|
||||
const personality = await this.prisma.personality.findFirst({
|
||||
where: { workspaceId, name },
|
||||
});
|
||||
@@ -86,13 +119,13 @@ export class PersonalitiesService {
|
||||
throw new NotFoundException(`Personality with name "${name}" not found`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the default personality for a workspace
|
||||
* Find the default (and enabled) personality for a workspace
|
||||
*/
|
||||
async findDefault(workspaceId: string): Promise<Personality> {
|
||||
async findDefault(workspaceId: string): Promise<PersonalityResponse> {
|
||||
const personality = await this.prisma.personality.findFirst({
|
||||
where: { workspaceId, isDefault: true, isEnabled: true },
|
||||
});
|
||||
@@ -101,14 +134,18 @@ export class PersonalitiesService {
|
||||
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing personality
|
||||
*/
|
||||
async update(workspaceId: string, id: string, dto: UpdatePersonalityDto): Promise<Personality> {
|
||||
// Check existence
|
||||
async update(
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
dto: UpdatePersonalityDto
|
||||
): Promise<PersonalityResponse> {
|
||||
// Verify existence
|
||||
await this.findOne(workspaceId, id);
|
||||
|
||||
// Check for duplicate name if updating name
|
||||
@@ -127,20 +164,43 @@ export class PersonalitiesService {
|
||||
await this.unsetOtherDefaults(workspaceId, id);
|
||||
}
|
||||
|
||||
// Build update data with field mapping
|
||||
const updateData: {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
tone?: string;
|
||||
formalityLevel?: FormalityLevel;
|
||||
systemPrompt?: string;
|
||||
isDefault?: boolean;
|
||||
isEnabled?: boolean;
|
||||
} = {};
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
updateData.name = dto.name;
|
||||
updateData.displayName = dto.name;
|
||||
}
|
||||
if (dto.description !== undefined) updateData.description = dto.description;
|
||||
if (dto.tone !== undefined) updateData.tone = dto.tone;
|
||||
if (dto.formalityLevel !== undefined) updateData.formalityLevel = dto.formalityLevel;
|
||||
if (dto.systemPromptTemplate !== undefined) updateData.systemPrompt = dto.systemPromptTemplate;
|
||||
if (dto.isDefault !== undefined) updateData.isDefault = dto.isDefault;
|
||||
if (dto.isActive !== undefined) updateData.isEnabled = dto.isActive;
|
||||
|
||||
const personality = await this.prisma.personality.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a personality
|
||||
*/
|
||||
async delete(workspaceId: string, id: string): Promise<void> {
|
||||
// Check existence
|
||||
// Verify existence
|
||||
await this.findOne(workspaceId, id);
|
||||
|
||||
await this.prisma.personality.delete({
|
||||
@@ -151,23 +211,22 @@ export class PersonalitiesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a personality as the default
|
||||
* Set a personality as the default (convenience endpoint)
|
||||
*/
|
||||
async setDefault(workspaceId: string, id: string): Promise<Personality> {
|
||||
// Check existence
|
||||
async setDefault(workspaceId: string, id: string): Promise<PersonalityResponse> {
|
||||
// Verify existence
|
||||
await this.findOne(workspaceId, id);
|
||||
|
||||
// Unset other defaults
|
||||
await this.unsetOtherDefaults(workspaceId, id);
|
||||
|
||||
// Set this one as default
|
||||
const personality = await this.prisma.personality.update({
|
||||
where: { id },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
|
||||
this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,7 +237,7 @@ export class PersonalitiesService {
|
||||
where: {
|
||||
workspaceId,
|
||||
isDefault: true,
|
||||
...(excludeId && { id: { not: excludeId } }),
|
||||
...(excludeId !== undefined && { id: { not: excludeId } }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -140,8 +140,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
workspaceId: string,
|
||||
client: PrismaClient = this
|
||||
): Promise<void> {
|
||||
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
||||
await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
|
||||
// Use set_config() instead of SET LOCAL so values are safely parameterized.
|
||||
// SET LOCAL with Prisma's tagged template produces invalid SQL (bind parameter $1
|
||||
// is not supported in SET statements by PostgreSQL).
|
||||
await client.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`;
|
||||
await client.$executeRaw`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,8 +154,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
* @param client - Optional Prisma client (uses 'this' if not provided)
|
||||
*/
|
||||
async clearWorkspaceContext(client: PrismaClient = this): Promise<void> {
|
||||
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
|
||||
await client.$executeRaw`SET LOCAL app.current_workspace_id = NULL`;
|
||||
await client.$executeRaw`SELECT set_config('app.current_user_id', '', true)`;
|
||||
await client.$executeRaw`SELECT set_config('app.current_workspace_id', '', true)`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
apps/api/src/users/preferences.controller.spec.ts
Normal file
112
apps/api/src/users/preferences.controller.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { UnauthorizedException } from "@nestjs/common";
|
||||
import { PreferencesController } from "./preferences.controller";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import type { UpdatePreferencesDto, PreferencesResponseDto } from "./dto";
|
||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
|
||||
describe("PreferencesController", () => {
|
||||
let controller: PreferencesController;
|
||||
let service: PreferencesService;
|
||||
|
||||
const mockPreferencesService = {
|
||||
getPreferences: vi.fn(),
|
||||
updatePreferences: vi.fn(),
|
||||
};
|
||||
|
||||
const mockUserId = "user-uuid-123";
|
||||
|
||||
const mockPreferencesResponse: PreferencesResponseDto = {
|
||||
id: "pref-uuid-456",
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
timezone: null,
|
||||
settings: {},
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
function makeRequest(userId?: string): AuthenticatedRequest {
|
||||
return {
|
||||
user: userId ? { id: userId } : undefined,
|
||||
} as unknown as AuthenticatedRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
service = mockPreferencesService as unknown as PreferencesService;
|
||||
controller = new PreferencesController(service);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/users/me/preferences", () => {
|
||||
it("should return preferences for authenticated user", async () => {
|
||||
mockPreferencesService.getPreferences.mockResolvedValue(mockPreferencesResponse);
|
||||
|
||||
const result = await controller.getPreferences(makeRequest(mockUserId));
|
||||
|
||||
expect(result).toEqual(mockPreferencesResponse);
|
||||
expect(mockPreferencesService.getPreferences).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||
await expect(controller.getPreferences(makeRequest())).rejects.toThrow(UnauthorizedException);
|
||||
expect(mockPreferencesService.getPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/users/me/preferences", () => {
|
||||
const updateDto: UpdatePreferencesDto = {
|
||||
theme: "dark",
|
||||
locale: "fr",
|
||||
timezone: "Europe/Paris",
|
||||
};
|
||||
|
||||
it("should update and return preferences for authenticated user", async () => {
|
||||
const updatedResponse: PreferencesResponseDto = {
|
||||
...mockPreferencesResponse,
|
||||
theme: "dark",
|
||||
locale: "fr",
|
||||
timezone: "Europe/Paris",
|
||||
};
|
||||
mockPreferencesService.updatePreferences.mockResolvedValue(updatedResponse);
|
||||
|
||||
const result = await controller.updatePreferences(updateDto, makeRequest(mockUserId));
|
||||
|
||||
expect(result).toEqual(updatedResponse);
|
||||
expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, updateDto);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||
await expect(controller.updatePreferences(updateDto, makeRequest())).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/users/me/preferences", () => {
|
||||
const patchDto: UpdatePreferencesDto = {
|
||||
theme: "light",
|
||||
};
|
||||
|
||||
it("should partially update and return preferences for authenticated user", async () => {
|
||||
const patchedResponse: PreferencesResponseDto = {
|
||||
...mockPreferencesResponse,
|
||||
theme: "light",
|
||||
};
|
||||
mockPreferencesService.updatePreferences.mockResolvedValue(patchedResponse);
|
||||
|
||||
const result = await controller.patchPreferences(patchDto, makeRequest(mockUserId));
|
||||
|
||||
expect(result).toEqual(patchedResponse);
|
||||
expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, patchDto);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||
await expect(controller.patchPreferences(patchDto, makeRequest())).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Patch,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
@@ -38,7 +39,7 @@ export class PreferencesController {
|
||||
|
||||
/**
|
||||
* PUT /api/users/me/preferences
|
||||
* Update current user's preferences
|
||||
* Full replace of current user's preferences
|
||||
*/
|
||||
@Put()
|
||||
async updatePreferences(
|
||||
@@ -53,4 +54,22 @@ export class PreferencesController {
|
||||
|
||||
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/users/me/preferences
|
||||
* Partial update of current user's preferences
|
||||
*/
|
||||
@Patch()
|
||||
async patchPreferences(
|
||||
@Body() updatePreferencesDto: UpdatePreferencesDto,
|
||||
@Request() req: AuthenticatedRequest
|
||||
) {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
|
||||
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||
}
|
||||
}
|
||||
|
||||
141
apps/api/src/users/preferences.service.spec.ts
Normal file
141
apps/api/src/users/preferences.service.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import type { PrismaService } from "../prisma/prisma.service";
|
||||
import type { UpdatePreferencesDto } from "./dto";
|
||||
|
||||
describe("PreferencesService", () => {
|
||||
let service: PreferencesService;
|
||||
|
||||
const mockPrisma = {
|
||||
userPreference: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockUserId = "user-uuid-123";
|
||||
|
||||
const mockDbPreference = {
|
||||
id: "pref-uuid-456",
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
timezone: null,
|
||||
settings: {},
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PreferencesService(mockPrisma as unknown as PrismaService);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getPreferences", () => {
|
||||
it("should return existing preferences", async () => {
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
|
||||
const result = await service.getPreferences(mockUserId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: mockDbPreference.id,
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
timezone: null,
|
||||
settings: {},
|
||||
});
|
||||
expect(mockPrisma.userPreference.findUnique).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
});
|
||||
expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create default preferences when none exist", async () => {
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.userPreference.create.mockResolvedValue(mockDbPreference);
|
||||
|
||||
const result = await service.getPreferences(mockUserId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: mockDbPreference.id,
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
});
|
||||
expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updatePreferences", () => {
|
||||
it("should update existing preferences", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { theme: "dark", locale: "fr" };
|
||||
const updatedPreference = { ...mockDbPreference, theme: "dark", locale: "fr" };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result).toMatchObject({ theme: "dark", locale: "fr" });
|
||||
expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
data: expect.objectContaining({ theme: "dark", locale: "fr" }),
|
||||
});
|
||||
expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create preferences when updating non-existent record", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { theme: "light" };
|
||||
const createdPreference = { ...mockDbPreference, theme: "light" };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.userPreference.create.mockResolvedValue(createdPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result).toMatchObject({ theme: "light" });
|
||||
expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: mockUserId,
|
||||
theme: "light",
|
||||
}),
|
||||
});
|
||||
expect(mockPrisma.userPreference.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle timezone update", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { timezone: "America/New_York" };
|
||||
const updatedPreference = { ...mockDbPreference, timezone: "America/New_York" };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result.timezone).toBe("America/New_York");
|
||||
expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
data: expect.objectContaining({ timezone: "America/New_York" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle settings update", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { settings: { notifications: true } };
|
||||
const updatedPreference = { ...mockDbPreference, settings: { notifications: true } };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result.settings).toEqual({ notifications: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { getTrustedOrigins } from "../auth/auth.config";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
@@ -77,7 +78,7 @@ interface StepOutputData {
|
||||
*/
|
||||
@WSGateway({
|
||||
cors: {
|
||||
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
||||
origin: getTrustedOrigins(),
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
@@ -167,17 +168,36 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Extract authentication token from Socket.IO handshake
|
||||
* @description Extract authentication token from Socket.IO handshake.
|
||||
*
|
||||
* Checks sources in order:
|
||||
* 1. handshake.auth.token — explicit token (e.g. from API clients)
|
||||
* 2. handshake.headers.cookie — session cookie sent by browser via withCredentials
|
||||
* 3. query.token — URL query parameter fallback
|
||||
* 4. Authorization header — Bearer token fallback
|
||||
*
|
||||
* @param client - The socket client
|
||||
* @returns The token string or undefined if not found
|
||||
*/
|
||||
private extractTokenFromHandshake(client: Socket): string | undefined {
|
||||
// Check handshake.auth.token (preferred method)
|
||||
// Check handshake.auth.token (preferred method for non-browser clients)
|
||||
const authToken = client.handshake.auth.token as unknown;
|
||||
if (typeof authToken === "string" && authToken.length > 0) {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
// Fallback: parse session cookie from request headers.
|
||||
// Browsers send httpOnly cookies automatically when withCredentials: true is set
|
||||
// on the socket.io client. BetterAuth uses one of these cookie names depending
|
||||
// on whether the connection is HTTPS (Secure prefix) or HTTP (dev).
|
||||
const cookieHeader = client.handshake.headers.cookie;
|
||||
if (typeof cookieHeader === "string" && cookieHeader.length > 0) {
|
||||
const cookieToken = this.extractTokenFromCookieHeader(cookieHeader);
|
||||
if (cookieToken) {
|
||||
return cookieToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check query parameters
|
||||
const queryToken = client.handshake.query.token as unknown;
|
||||
if (typeof queryToken === "string" && queryToken.length > 0) {
|
||||
@@ -197,6 +217,45 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Parse the BetterAuth session token from a raw Cookie header string.
|
||||
*
|
||||
* BetterAuth names the session cookie differently based on the security context:
|
||||
* - `__Secure-better-auth.session_token` — HTTPS with Secure flag
|
||||
* - `better-auth.session_token` — HTTP (development)
|
||||
* - `__Host-better-auth.session_token` — HTTPS with Host prefix
|
||||
*
|
||||
* @param cookieHeader - The raw Cookie header value
|
||||
* @returns The session token value or undefined if no matching cookie found
|
||||
*/
|
||||
private extractTokenFromCookieHeader(cookieHeader: string): string | undefined {
|
||||
const SESSION_COOKIE_NAMES = [
|
||||
"__Secure-better-auth.session_token",
|
||||
"better-auth.session_token",
|
||||
"__Host-better-auth.session_token",
|
||||
] as const;
|
||||
|
||||
// Parse the Cookie header into a key-value map
|
||||
const cookies = Object.fromEntries(
|
||||
cookieHeader.split(";").map((pair) => {
|
||||
const eqIndex = pair.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
return [pair.trim(), ""];
|
||||
}
|
||||
return [pair.slice(0, eqIndex).trim(), pair.slice(eqIndex + 1).trim()];
|
||||
})
|
||||
);
|
||||
|
||||
for (const name of SESSION_COOKIE_NAMES) {
|
||||
const value = cookies[name];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Handle client disconnect by leaving the workspace room.
|
||||
* @param client - The socket client containing workspaceId in data.
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
|
||||
import { WidgetsService } from "./widgets.service";
|
||||
import { WidgetDataService } from "./widget-data.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
|
||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import type { RequestWithWorkspace } from "../common/types/user.types";
|
||||
|
||||
/**
|
||||
* Controller for widget definition and data endpoints
|
||||
* All endpoints require authentication
|
||||
* All endpoints require authentication; data endpoints also require workspace context
|
||||
*/
|
||||
@Controller("widgets")
|
||||
@UseGuards(AuthGuard)
|
||||
@@ -51,12 +43,9 @@ export class WidgetsController {
|
||||
* Get stat card widget data
|
||||
*/
|
||||
@Post("data/stat-card")
|
||||
async getStatCardData(@Request() req: AuthenticatedRequest, @Body() query: StatCardQueryDto) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getStatCardData(workspaceId, query);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
|
||||
return this.widgetDataService.getStatCardData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,12 +53,9 @@ export class WidgetsController {
|
||||
* Get chart widget data
|
||||
*/
|
||||
@Post("data/chart")
|
||||
async getChartData(@Request() req: AuthenticatedRequest, @Body() query: ChartQueryDto) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getChartData(workspaceId, query);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
|
||||
return this.widgetDataService.getChartData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,12 +63,9 @@ export class WidgetsController {
|
||||
* Get list widget data
|
||||
*/
|
||||
@Post("data/list")
|
||||
async getListData(@Request() req: AuthenticatedRequest, @Body() query: ListQueryDto) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getListData(workspaceId, query);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
|
||||
return this.widgetDataService.getListData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,15 +73,12 @@ export class WidgetsController {
|
||||
* Get calendar preview widget data
|
||||
*/
|
||||
@Post("data/calendar-preview")
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getCalendarPreviewData(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Request() req: RequestWithWorkspace,
|
||||
@Body() query: CalendarPreviewQueryDto
|
||||
) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getCalendarPreviewData(workspaceId, query);
|
||||
return this.widgetDataService.getCalendarPreviewData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,12 +86,9 @@ export class WidgetsController {
|
||||
* Get active projects widget data
|
||||
*/
|
||||
@Post("data/active-projects")
|
||||
async getActiveProjectsData(@Request() req: AuthenticatedRequest) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getActiveProjectsData(workspaceId);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
|
||||
return this.widgetDataService.getActiveProjectsData(req.workspace.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,11 +96,8 @@ export class WidgetsController {
|
||||
* Get agent chains widget data (active agent sessions)
|
||||
*/
|
||||
@Post("data/agent-chains")
|
||||
async getAgentChainsData(@Request() req: AuthenticatedRequest) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getAgentChainsData(workspaceId);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getAgentChainsData(@Request() req: RequestWithWorkspace) {
|
||||
return this.widgetDataService.getAgentChainsData(req.workspace.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/orchestrator",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/web",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
@@ -33,6 +33,9 @@
|
||||
"@tiptap/react": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.20.0",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.5.3",
|
||||
"better-auth": "^1.4.17",
|
||||
"date-fns": "^4.1.0",
|
||||
|
||||
@@ -128,12 +128,31 @@ function LoginPageContent(): ReactElement {
|
||||
setError(null);
|
||||
const callbackURL =
|
||||
typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/";
|
||||
signIn.oauth2({ providerId, callbackURL }).catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
||||
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
||||
setOauthLoading(null);
|
||||
});
|
||||
signIn
|
||||
.oauth2({ providerId, callbackURL })
|
||||
.then((result) => {
|
||||
// BetterAuth returns Data | Error union — check for error or missing redirect URL
|
||||
const hasError = "error" in result && result.error;
|
||||
const hasUrl = "data" in result && result.data?.url;
|
||||
if (hasError || !hasUrl) {
|
||||
const errObj = hasError ? result.error : null;
|
||||
const message =
|
||||
errObj && typeof errObj === "object" && "message" in errObj
|
||||
? String(errObj.message)
|
||||
: "no redirect URL";
|
||||
console.error(`[Auth] OAuth sign-in failed for ${providerId}:`, message);
|
||||
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
||||
setOauthLoading(null);
|
||||
}
|
||||
// If data.url exists, BetterAuth's client will redirect the browser automatically.
|
||||
// No need to reset loading — the page is navigating away.
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
||||
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
||||
setOauthLoading(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCredentialsLogin = useCallback(
|
||||
@@ -307,7 +326,7 @@ function LoginPageContent(): ReactElement {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-center">
|
||||
<AuthStatusPill label="Mosaic v0.1" tone="neutral" />
|
||||
<AuthStatusPill label="Mosaic v0.0.20" tone="neutral" />
|
||||
</div>
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function ProfilePage(): ReactElement {
|
||||
setPrefsError(null);
|
||||
|
||||
try {
|
||||
const data = await apiGet<UserPreferences>("/users/me/preferences");
|
||||
const data = await apiGet<UserPreferences>("/api/users/me/preferences");
|
||||
setPreferences(data);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Could not load preferences";
|
||||
|
||||
@@ -240,7 +240,7 @@ export default function AppearanceSettingsPage(): ReactElement {
|
||||
setLocalTheme(themeId);
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiPatch("/users/me/preferences", { theme: themeId });
|
||||
await apiPatch("/api/users/me/preferences", { theme: themeId });
|
||||
} catch {
|
||||
// Theme is still applied locally even if API save fails
|
||||
} finally {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
const ACTIVITY_ACTIONS = [
|
||||
{ value: "CREDENTIAL_CREATED", label: "Created" },
|
||||
@@ -39,17 +40,17 @@ export default function CredentialAuditPage(): React.ReactElement {
|
||||
const [filters, setFilters] = useState<FilterState>({});
|
||||
const [hasFilters, setHasFilters] = useState(false);
|
||||
|
||||
// TODO: Get workspace ID from context/auth
|
||||
const workspaceId = "default-workspace-id"; // Placeholder
|
||||
const workspaceId = useWorkspaceId();
|
||||
|
||||
useEffect(() => {
|
||||
void loadLogs();
|
||||
}, [page, filters]);
|
||||
if (!workspaceId) return;
|
||||
void loadLogs(workspaceId);
|
||||
}, [workspaceId, page, filters]);
|
||||
|
||||
async function loadLogs(): Promise<void> {
|
||||
async function loadLogs(wsId: string): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchCredentialAuditLog(workspaceId, {
|
||||
const response = await fetchCredentialAuditLog(wsId, {
|
||||
...filters,
|
||||
page,
|
||||
limit,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, type SyntheticEvent } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import type { Domain } from "@mosaic/shared";
|
||||
import { DomainList } from "@/components/domains/DomainList";
|
||||
import { fetchDomains, deleteDomain } from "@/lib/api/domains";
|
||||
import { fetchDomains, createDomain, deleteDomain } from "@/lib/api/domains";
|
||||
import type { CreateDomainDto } from "@/lib/api/domains";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
export default function DomainsPage(): React.ReactElement {
|
||||
/* ---------------------------------------------------------------------------
|
||||
Slug generation helper
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Create Domain Dialog
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface CreateDomainDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: CreateDomainDto) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function CreateDomainDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: CreateDomainDialogProps): ReactElement | null {
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
function resetForm(): void {
|
||||
setName("");
|
||||
setSlug("");
|
||||
setSlugTouched(false);
|
||||
setDescription("");
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
function handleNameChange(value: string): void {
|
||||
setName(value);
|
||||
if (!slugTouched) {
|
||||
setSlug(generateSlug(value));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlugChange(value: string): void {
|
||||
setSlugTouched(true);
|
||||
setSlug(value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setFormError("Domain name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedSlug = slug.trim();
|
||||
if (!trimmedSlug) {
|
||||
setFormError("Slug is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(trimmedSlug)) {
|
||||
setFormError("Slug must contain only lowercase letters, numbers, and hyphens.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: CreateDomainDto = { name: trimmedName, slug: trimmedSlug };
|
||||
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 domain.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-domain-title"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isSubmitting) {
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
background: "var(--surface, #fff)",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid var(--border, #e5e7eb)",
|
||||
padding: 24,
|
||||
width: "100%",
|
||||
maxWidth: 480,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
id="create-domain-title"
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text, #111)",
|
||||
margin: "0 0 8px",
|
||||
}}
|
||||
>
|
||||
New Domain
|
||||
</h2>
|
||||
<p style={{ color: "var(--muted, #6b7280)", fontSize: "0.875rem", margin: "0 0 16px" }}>
|
||||
Domains help you organize tasks, projects, and events by life area.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(e);
|
||||
}}
|
||||
>
|
||||
{/* Name */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
htmlFor="domain-name"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-2, #374151)",
|
||||
}}
|
||||
>
|
||||
Name <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="domain-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
handleNameChange(e.target.value);
|
||||
}}
|
||||
placeholder="e.g. Personal Finance"
|
||||
maxLength={255}
|
||||
autoFocus
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg, #f9fafb)",
|
||||
border: "1px solid var(--border, #d1d5db)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text, #111)",
|
||||
fontSize: "0.9rem",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
htmlFor="domain-slug"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-2, #374151)",
|
||||
}}
|
||||
>
|
||||
Slug <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="domain-slug"
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
handleSlugChange(e.target.value);
|
||||
}}
|
||||
placeholder="e.g. personal-finance"
|
||||
maxLength={100}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg, #f9fafb)",
|
||||
border: "1px solid var(--border, #d1d5db)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text, #111)",
|
||||
fontSize: "0.9rem",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
fontFamily: "var(--mono, monospace)",
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--muted, #6b7280)",
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
Lowercase letters, numbers, and hyphens only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
htmlFor="domain-description"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-2, #374151)",
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="domain-description"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
}}
|
||||
placeholder="A brief summary of this domain..."
|
||||
rows={3}
|
||||
maxLength={10000}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg, #f9fafb)",
|
||||
border: "1px solid var(--border, #d1d5db)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text, #111)",
|
||||
fontSize: "0.9rem",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form error */}
|
||||
{formError !== null && (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--danger, #ef4444)",
|
||||
fontSize: "0.85rem",
|
||||
margin: "0 0 12px",
|
||||
}}
|
||||
>
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border, #d1d5db)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text-2, #374151)",
|
||||
fontSize: "0.85rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !name.trim() || !slug.trim()}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--primary, #111827)",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: isSubmitting || !name.trim() || !slug.trim() ? "not-allowed" : "pointer",
|
||||
opacity: isSubmitting || !name.trim() || !slug.trim() ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create Domain"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Domains Page
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
export default function DomainsPage(): ReactElement {
|
||||
const workspaceId = useWorkspaceId();
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Create dialog state
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
void loadDomains();
|
||||
}, []);
|
||||
}, [workspaceId]);
|
||||
|
||||
async function loadDomains(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchDomains();
|
||||
const response = await fetchDomains(undefined, workspaceId ?? undefined);
|
||||
setDomains(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -27,9 +387,8 @@ export default function DomainsPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(domain: Domain): void {
|
||||
function handleEdit(_domain: Domain): void {
|
||||
// TODO: Open edit modal/form
|
||||
console.log("Edit domain:", domain);
|
||||
}
|
||||
|
||||
async function handleDelete(domain: Domain): Promise<void> {
|
||||
@@ -38,13 +397,26 @@ export default function DomainsPage(): React.ReactElement {
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteDomain(domain.id);
|
||||
await deleteDomain(domain.id, workspaceId ?? undefined);
|
||||
await loadDomains();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete domain");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(data: CreateDomainDto): Promise<void> {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createDomain(data, workspaceId ?? undefined);
|
||||
setCreateOpen(false);
|
||||
await loadDomains();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create domain.");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
@@ -60,7 +432,7 @@ export default function DomainsPage(): React.ReactElement {
|
||||
<button
|
||||
className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800"
|
||||
onClick={() => {
|
||||
console.log("TODO: Open create modal");
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Domain
|
||||
@@ -73,6 +445,13 @@ export default function DomainsPage(): React.ReactElement {
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<CreateDomainDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onSubmit={handleCreate}
|
||||
isSubmitting={isCreating}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
63
apps/web/src/app/(authenticated)/terminal/page.tsx
Normal file
63
apps/web/src/app/(authenticated)/terminal/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Terminal page — dedicated full-screen terminal route at /terminal.
|
||||
*
|
||||
* Renders the TerminalPanel component filling the available content area.
|
||||
* The panel is always open on this page; there is no close action since
|
||||
* the user navigates away using the sidebar instead.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { TerminalPanel } from "@/components/terminal";
|
||||
import { getAccessToken } from "@/lib/auth-client";
|
||||
|
||||
export default function TerminalPage(): ReactElement {
|
||||
const [token, setToken] = useState<string>("");
|
||||
|
||||
// Resolve the access token once on mount. The WebSocket connection inside
|
||||
// TerminalPanel uses this token for authentication.
|
||||
useEffect((): void => {
|
||||
getAccessToken()
|
||||
.then((t) => {
|
||||
setToken(t ?? "");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("[TerminalPage] Failed to retrieve access token:", err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Override TerminalPanel inline height so it fills the page */}
|
||||
<style>{`
|
||||
.terminal-page-panel {
|
||||
height: 100% !important;
|
||||
border-top: none !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
aria-label="Terminal"
|
||||
>
|
||||
<TerminalPanel
|
||||
open={true}
|
||||
onClose={(): void => {
|
||||
/* No-op: on the dedicated terminal page the panel is always open.
|
||||
Users navigate away using the sidebar rather than closing the panel. */
|
||||
}}
|
||||
token={token}
|
||||
className="terminal-page-panel"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
apps/web/src/app/favicon.ico
Normal file
BIN
apps/web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 539 B |
@@ -765,6 +765,28 @@ body::before {
|
||||
animation: scaleIn 0.1s ease-out;
|
||||
}
|
||||
|
||||
/* Streaming cursor for real-time token rendering */
|
||||
@keyframes streaming-cursor-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.streaming-cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
background-color: rgb(var(--accent-primary));
|
||||
border-radius: 1px;
|
||||
animation: streaming-cursor-blink 1s step-end infinite;
|
||||
vertical-align: text-bottom;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Dashboard Layout — Responsive Grids
|
||||
----------------------------------------------------------------------------- */
|
||||
|
||||
@@ -11,6 +11,9 @@ export const dynamic = "force-dynamic";
|
||||
export const metadata: Metadata = {
|
||||
title: "Mosaic Stack",
|
||||
description: "Mosaic Stack Web Application",
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
};
|
||||
|
||||
const outfit = Outfit({
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
*/
|
||||
|
||||
import { createRef } from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { render, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest";
|
||||
import { Chat, type ChatRef } from "./Chat";
|
||||
import * as useChatModule from "@/hooks/useChat";
|
||||
import * as useWebSocketModule from "@/hooks/useWebSocket";
|
||||
import * as authModule from "@/lib/auth/auth-context";
|
||||
import * as orchestratorModule from "@/hooks/useOrchestratorCommands";
|
||||
|
||||
// Mock scrollIntoView (not available in JSDOM)
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
@@ -39,10 +40,28 @@ vi.mock("./ChatInput", () => ({
|
||||
disabled: boolean;
|
||||
inputRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
}): React.ReactElement => (
|
||||
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
|
||||
Send
|
||||
</button>
|
||||
<>
|
||||
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
|
||||
Send
|
||||
</button>
|
||||
<button data-testid="chat-input-command" onClick={(): void => void onSend("/status")}>
|
||||
Send Command
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
DEFAULT_TEMPERATURE: 0.7,
|
||||
DEFAULT_MAX_TOKENS: 4096,
|
||||
DEFAULT_MODEL: "llama3.2",
|
||||
AVAILABLE_MODELS: [
|
||||
{ id: "llama3.2", label: "Llama 3.2" },
|
||||
{ id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" },
|
||||
{ id: "gpt-4o", label: "GPT-4o" },
|
||||
{ id: "deepseek-r1", label: "DeepSeek R1" },
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useOrchestratorCommands", () => ({
|
||||
useOrchestratorCommands: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseAuth = authModule.useAuth as MockedFunction<typeof authModule.useAuth>;
|
||||
@@ -50,6 +69,9 @@ const mockUseChat = useChatModule.useChat as MockedFunction<typeof useChatModule
|
||||
const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction<
|
||||
typeof useWebSocketModule.useWebSocket
|
||||
>;
|
||||
const mockUseOrchestratorCommands = orchestratorModule.useOrchestratorCommands as MockedFunction<
|
||||
typeof orchestratorModule.useOrchestratorCommands
|
||||
>;
|
||||
|
||||
function createMockUseChatReturn(
|
||||
overrides: Partial<useChatModule.UseChatReturn> = {}
|
||||
@@ -64,10 +86,12 @@ function createMockUseChatReturn(
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
conversationId: null,
|
||||
conversationTitle: null,
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
abortStream: vi.fn(),
|
||||
loadConversation: vi.fn().mockResolvedValue(undefined),
|
||||
startNewConversation: vi.fn(),
|
||||
setMessages: vi.fn(),
|
||||
@@ -96,6 +120,12 @@ describe("Chat", () => {
|
||||
socket: null,
|
||||
connectionError: null,
|
||||
});
|
||||
|
||||
// Default: no commands intercepted
|
||||
mockUseOrchestratorCommands.mockReturnValue({
|
||||
isCommand: vi.fn().mockReturnValue(false),
|
||||
executeCommand: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -149,4 +179,105 @@ describe("Chat", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("orchestrator command routing", () => {
|
||||
it("routes command messages through orchestrator instead of LLM", async () => {
|
||||
const mockSendMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSetMessages = vi.fn();
|
||||
const mockExecuteCommand = vi.fn().mockResolvedValue({
|
||||
id: "orch-123",
|
||||
role: "assistant" as const,
|
||||
content: "**Orchestrator Status**\n\n| Field | Value |\n|---|---|\n| Status | **Ready** |",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
mockUseChat.mockReturnValue(
|
||||
createMockUseChatReturn({
|
||||
sendMessage: mockSendMessage,
|
||||
setMessages: mockSetMessages,
|
||||
})
|
||||
);
|
||||
|
||||
mockUseOrchestratorCommands.mockReturnValue({
|
||||
isCommand: (content: string) => content.trim().startsWith("/"),
|
||||
executeCommand: mockExecuteCommand,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(<Chat />);
|
||||
|
||||
const commandButton = getByTestId("chat-input-command");
|
||||
fireEvent.click(commandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// executeCommand should have been called with the slash command
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith("/status");
|
||||
});
|
||||
|
||||
// sendMessage should NOT have been called
|
||||
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||
|
||||
// setMessages should have been called to add user and assistant messages
|
||||
await waitFor(() => {
|
||||
expect(mockSetMessages).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call orchestrator for regular messages", async () => {
|
||||
const mockSendMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const mockExecuteCommand = vi.fn().mockResolvedValue(null);
|
||||
|
||||
mockUseChat.mockReturnValue(createMockUseChatReturn({ sendMessage: mockSendMessage }));
|
||||
|
||||
mockUseOrchestratorCommands.mockReturnValue({
|
||||
isCommand: vi.fn().mockReturnValue(false),
|
||||
executeCommand: mockExecuteCommand,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(<Chat />);
|
||||
|
||||
fireEvent.click(getByTestId("chat-input"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendMessage).toHaveBeenCalledWith("test message");
|
||||
});
|
||||
|
||||
expect(mockExecuteCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still adds user message to chat for commands", async () => {
|
||||
const mockSetMessages = vi.fn();
|
||||
const mockExecuteCommand = vi.fn().mockResolvedValue({
|
||||
id: "orch-456",
|
||||
role: "assistant" as const,
|
||||
content: "Help content",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
mockUseChat.mockReturnValue(createMockUseChatReturn({ setMessages: mockSetMessages }));
|
||||
|
||||
mockUseOrchestratorCommands.mockReturnValue({
|
||||
isCommand: (content: string) => content.trim().startsWith("/"),
|
||||
executeCommand: mockExecuteCommand,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(<Chat />);
|
||||
|
||||
fireEvent.click(getByTestId("chat-input-command"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetMessages).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// First setMessages call should add the user message
|
||||
const firstCall = mockSetMessages.mock.calls[0];
|
||||
if (!firstCall) throw new Error("Expected setMessages to have been called");
|
||||
const updater = firstCall[0] as (prev: useChatModule.Message[]) => useChatModule.Message[];
|
||||
const result = updater([]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: "/status",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { useChat } from "@/hooks/useChat";
|
||||
import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands";
|
||||
import { useWebSocket } from "@/hooks/useWebSocket";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
|
||||
import { ChatEmptyState } from "./ChatEmptyState";
|
||||
import type { Message } from "@/hooks/useChat";
|
||||
|
||||
export interface ChatRef {
|
||||
@@ -59,31 +61,41 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
|
||||
// Use the chat hook for state management
|
||||
// Model and params state — initialized from ChatInput's persisted values
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2");
|
||||
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
|
||||
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
|
||||
|
||||
// Suggestion fill value: controls ChatInput's textarea content
|
||||
const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined);
|
||||
|
||||
const {
|
||||
messages,
|
||||
isLoading: isChatLoading,
|
||||
isStreaming,
|
||||
error,
|
||||
conversationId,
|
||||
conversationTitle,
|
||||
sendMessage,
|
||||
abortStream,
|
||||
loadConversation,
|
||||
startNewConversation,
|
||||
setMessages,
|
||||
clearError,
|
||||
} = useChat({
|
||||
model: "llama3.2",
|
||||
model: selectedModel,
|
||||
temperature,
|
||||
maxTokens,
|
||||
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
||||
});
|
||||
|
||||
// Connect to WebSocket for real-time updates (when we have a user)
|
||||
const { isConnected: isWsConnected } = useWebSocket(
|
||||
user?.id ?? "", // Use user ID as workspace ID for now
|
||||
"", // Token not needed since we use cookies
|
||||
{
|
||||
// Future: Add handlers for chat-related events
|
||||
// onChatMessage: (msg) => { ... }
|
||||
}
|
||||
);
|
||||
// Use the actual workspace ID for the WebSocket room subscription.
|
||||
// Cookie-based auth (withCredentials) handles authentication, so no explicit
|
||||
// token is needed here — pass an empty string as the token placeholder.
|
||||
const workspaceId = user?.currentWorkspaceId ?? user?.workspaceId ?? "";
|
||||
const { isConnected: isWsConnected } = useWebSocket(workspaceId, "", {});
|
||||
|
||||
const { isCommand, executeCommand } = useOrchestratorCommands();
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -91,7 +103,15 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
const quipTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const quipIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Expose methods to parent via ref
|
||||
// Identify the streaming message (last assistant message while streaming)
|
||||
const streamingMessageId =
|
||||
isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined;
|
||||
|
||||
// Whether the conversation is empty (only welcome message or no messages)
|
||||
const isEmptyConversation =
|
||||
messages.length === 0 ||
|
||||
(messages.length === 1 && messages[0]?.id === "welcome" && !isChatLoading && !isStreaming);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadConversation: async (cId: string): Promise<void> => {
|
||||
await loadConversation(cId);
|
||||
@@ -110,7 +130,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
// Notify parent of conversation changes
|
||||
useEffect(() => {
|
||||
if (conversationId && conversationTitle) {
|
||||
onConversationChange?.(conversationId, {
|
||||
@@ -125,34 +144,43 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
}
|
||||
}, [conversationId, conversationTitle, initialProjectId, onConversationChange]);
|
||||
|
||||
// Global keyboard shortcut: Ctrl+/ to focus input
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
// Cmd/Ctrl + / : Focus input
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
// Cmd/Ctrl + N : Start new conversation
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === "n" || e.key === "N")) {
|
||||
e.preventDefault();
|
||||
startNewConversation(null);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
// Cmd/Ctrl + L : Clear / start new conversation
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === "l" || e.key === "L")) {
|
||||
e.preventDefault();
|
||||
startNewConversation(null);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return (): void => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
}, [startNewConversation]);
|
||||
|
||||
// Show loading quips
|
||||
// Show loading quips only during non-streaming load (initial fetch wait)
|
||||
useEffect(() => {
|
||||
if (isChatLoading) {
|
||||
// Show first quip after 3 seconds
|
||||
if (isChatLoading && !isStreaming) {
|
||||
quipTimerRef.current = setTimeout(() => {
|
||||
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
||||
}, 3000);
|
||||
|
||||
// Change quip every 5 seconds
|
||||
quipIntervalRef.current = setInterval(() => {
|
||||
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
||||
}, 5000);
|
||||
} else {
|
||||
// Clear timers when loading stops
|
||||
if (quipTimerRef.current) {
|
||||
clearTimeout(quipTimerRef.current);
|
||||
quipTimerRef.current = null;
|
||||
@@ -168,16 +196,41 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
if (quipTimerRef.current) clearTimeout(quipTimerRef.current);
|
||||
if (quipIntervalRef.current) clearInterval(quipIntervalRef.current);
|
||||
};
|
||||
}, [isChatLoading]);
|
||||
}, [isChatLoading, isStreaming]);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (isCommand(content)) {
|
||||
// Add user message immediately
|
||||
const userMessage: Message = {
|
||||
id: `user-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
// Execute orchestrator command
|
||||
const result = await executeCommand(content);
|
||||
if (result) {
|
||||
setMessages((prev) => [...prev, result]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMessage(content);
|
||||
},
|
||||
[sendMessage]
|
||||
[isCommand, executeCommand, setMessages, sendMessage]
|
||||
);
|
||||
|
||||
// Show loading state while auth is loading
|
||||
const handleSuggestionClick = useCallback((prompt: string): void => {
|
||||
setSuggestionValue(prompt);
|
||||
// Clear after a tick so input receives it, then focus
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div
|
||||
@@ -224,11 +277,17 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-4xl px-4 py-6 lg:px-8">
|
||||
<MessageList
|
||||
messages={messages as (Message & { thinking?: string })[]}
|
||||
isLoading={isChatLoading}
|
||||
loadingQuip={loadingQuip}
|
||||
/>
|
||||
{isEmptyConversation ? (
|
||||
<ChatEmptyState onSuggestionClick={handleSuggestionClick} />
|
||||
) : (
|
||||
<MessageList
|
||||
messages={messages as (Message & { thinking?: string })[]}
|
||||
isLoading={isChatLoading}
|
||||
isStreaming={isStreaming}
|
||||
{...(streamingMessageId != null ? { streamingMessageId } : {})}
|
||||
loadingQuip={loadingQuip}
|
||||
/>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -294,6 +353,12 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
onSend={handleSendMessage}
|
||||
disabled={isChatLoading || !user}
|
||||
inputRef={inputRef}
|
||||
isStreaming={isStreaming}
|
||||
onStopStreaming={abortStream}
|
||||
onModelChange={setSelectedModel}
|
||||
onTemperatureChange={setTemperature}
|
||||
onMaxTokensChange={setMaxTokens}
|
||||
{...(suggestionValue !== undefined ? { externalValue: suggestionValue } : {})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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";
|
||||
|
||||
import type { KeyboardEvent, RefObject } from "react";
|
||||
import { useCallback, useState, useEffect } from "react";
|
||||
import { useCallback, useState, useEffect, useRef } from "react";
|
||||
import { ORCHESTRATOR_COMMANDS } from "@/hooks/useOrchestratorCommands";
|
||||
|
||||
export const AVAILABLE_MODELS = [
|
||||
{ id: "llama3.2", label: "Llama 3.2" },
|
||||
{ id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" },
|
||||
{ id: "gpt-4o", label: "GPT-4o" },
|
||||
{ id: "deepseek-r1", label: "DeepSeek R1" },
|
||||
] as const;
|
||||
|
||||
export type ModelId = (typeof AVAILABLE_MODELS)[number]["id"];
|
||||
|
||||
const STORAGE_KEY_MODEL = "chat:selectedModel";
|
||||
const STORAGE_KEY_TEMPERATURE = "chat:temperature";
|
||||
const STORAGE_KEY_MAX_TOKENS = "chat:maxTokens";
|
||||
|
||||
export const DEFAULT_TEMPERATURE = 0.7;
|
||||
export const DEFAULT_MAX_TOKENS = 4096;
|
||||
export const DEFAULT_MODEL: ModelId = "llama3.2";
|
||||
|
||||
function loadStoredModel(): ModelId {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_MODEL);
|
||||
if (stored && AVAILABLE_MODELS.some((m) => m.id === stored)) {
|
||||
return stored as ModelId;
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
function loadStoredTemperature(): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_TEMPERATURE);
|
||||
if (stored !== null) {
|
||||
const parsed = parseFloat(stored);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 2) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
return DEFAULT_TEMPERATURE;
|
||||
}
|
||||
|
||||
function loadStoredMaxTokens(): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_MAX_TOKENS);
|
||||
if (stored !== null) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (!isNaN(parsed) && parsed >= 100 && parsed <= 32000) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
return DEFAULT_MAX_TOKENS;
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||||
isStreaming?: boolean;
|
||||
onStopStreaming?: () => void;
|
||||
onModelChange?: (model: ModelId) => void;
|
||||
onTemperatureChange?: (temperature: number) => void;
|
||||
onMaxTokensChange?: (maxTokens: number) => void;
|
||||
onSuggestionFill?: (text: string) => void;
|
||||
externalValue?: string;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React.JSX.Element {
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
disabled,
|
||||
inputRef,
|
||||
isStreaming = false,
|
||||
onStopStreaming,
|
||||
onModelChange,
|
||||
onTemperatureChange,
|
||||
onMaxTokensChange,
|
||||
externalValue,
|
||||
}: ChatInputProps): React.JSX.Element {
|
||||
const [message, setMessage] = useState("");
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>(DEFAULT_MODEL);
|
||||
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
|
||||
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
|
||||
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
|
||||
const [isParamsOpen, setIsParamsOpen] = useState(false);
|
||||
|
||||
// Command autocomplete state
|
||||
const [commandSuggestions, setCommandSuggestions] = useState<typeof ORCHESTRATOR_COMMANDS>([]);
|
||||
const [highlightedCommandIndex, setHighlightedCommandIndex] = useState(0);
|
||||
const commandDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const modelDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const paramsDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Stable refs for callbacks so the mount effect stays dependency-free
|
||||
const onModelChangeRef = useRef(onModelChange);
|
||||
onModelChangeRef.current = onModelChange;
|
||||
const onTemperatureChangeRef = useRef(onTemperatureChange);
|
||||
onTemperatureChangeRef.current = onTemperatureChange;
|
||||
const onMaxTokensChangeRef = useRef(onMaxTokensChange);
|
||||
onMaxTokensChangeRef.current = onMaxTokensChange;
|
||||
|
||||
// Load persisted values from localStorage on mount only
|
||||
useEffect(() => {
|
||||
const storedModel = loadStoredModel();
|
||||
const storedTemperature = loadStoredTemperature();
|
||||
const storedMaxTokens = loadStoredMaxTokens();
|
||||
|
||||
setSelectedModel(storedModel);
|
||||
setTemperature(storedTemperature);
|
||||
setMaxTokens(storedMaxTokens);
|
||||
|
||||
// Notify parent of initial values via refs to avoid stale closure
|
||||
onModelChangeRef.current?.(storedModel);
|
||||
onTemperatureChangeRef.current?.(storedTemperature);
|
||||
onMaxTokensChangeRef.current?.(storedMaxTokens);
|
||||
}, []);
|
||||
|
||||
// Sync external value (e.g. from suggestion clicks)
|
||||
useEffect(() => {
|
||||
if (externalValue !== undefined) {
|
||||
setMessage(externalValue);
|
||||
}
|
||||
}, [externalValue]);
|
||||
|
||||
// Fetch version from static version.json (generated at build time)
|
||||
useEffect(() => {
|
||||
interface VersionData {
|
||||
version?: string;
|
||||
@@ -24,7 +144,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
.then((res) => res.json() as Promise<VersionData>)
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
// Format as "version+commit" for full build identification
|
||||
const fullVersion = data.commit ? `${data.version}+${data.commit}` : data.version;
|
||||
setVersion(fullVersion);
|
||||
}
|
||||
@@ -34,42 +153,451 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Update command autocomplete suggestions when message changes
|
||||
useEffect(() => {
|
||||
const trimmed = message.trimStart();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
setCommandSuggestions([]);
|
||||
setHighlightedCommandIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the input contains a space, a command has been completed — no suggestions
|
||||
if (trimmed.includes(" ")) {
|
||||
setCommandSuggestions([]);
|
||||
setHighlightedCommandIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const typedCommand = trimmed.toLowerCase();
|
||||
|
||||
// Build flat list including aliases
|
||||
const matches = ORCHESTRATOR_COMMANDS.filter((cmd) => {
|
||||
if (cmd.name.startsWith(typedCommand)) return true;
|
||||
if (cmd.aliases?.some((a) => a.startsWith(typedCommand))) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
setCommandSuggestions(matches);
|
||||
setHighlightedCommandIndex(0);
|
||||
}, [message]);
|
||||
|
||||
// Close dropdowns on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target as Node)) {
|
||||
setIsModelDropdownOpen(false);
|
||||
}
|
||||
if (paramsDropdownRef.current && !paramsDropdownRef.current.contains(e.target as Node)) {
|
||||
setIsParamsOpen(false);
|
||||
}
|
||||
if (commandDropdownRef.current && !commandDropdownRef.current.contains(e.target as Node)) {
|
||||
setCommandSuggestions([]);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return (): void => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (message.trim() && !disabled) {
|
||||
if (message.trim() && !disabled && !isStreaming) {
|
||||
onSend(message);
|
||||
setMessage("");
|
||||
}
|
||||
}, [message, onSend, disabled]);
|
||||
}, [message, onSend, disabled, isStreaming]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
onStopStreaming?.();
|
||||
}, [onStopStreaming]);
|
||||
|
||||
const acceptCommand = useCallback((cmdName: string): void => {
|
||||
setMessage(cmdName + " ");
|
||||
setCommandSuggestions([]);
|
||||
setHighlightedCommandIndex(0);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Enter to send (without Shift)
|
||||
// Command autocomplete navigation
|
||||
if (commandSuggestions.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightedCommandIndex((prev) =>
|
||||
prev < commandSuggestions.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightedCommandIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : commandSuggestions.length - 1
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
e.key === "Tab" ||
|
||||
(e.key === "Enter" && !e.shiftKey && commandSuggestions.length > 0)
|
||||
) {
|
||||
e.preventDefault();
|
||||
const selected = commandSuggestions[highlightedCommandIndex];
|
||||
if (selected) {
|
||||
acceptCommand(selected.name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setCommandSuggestions([]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
// Ctrl/Cmd + Enter to send (alternative)
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
[handleSubmit, commandSuggestions, highlightedCommandIndex, acceptCommand]
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(model: ModelId): void => {
|
||||
setSelectedModel(model);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_MODEL, model);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
onModelChange?.(model);
|
||||
setIsModelDropdownOpen(false);
|
||||
},
|
||||
[onModelChange]
|
||||
);
|
||||
|
||||
const handleTemperatureChange = useCallback(
|
||||
(value: number): void => {
|
||||
setTemperature(value);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_TEMPERATURE, value.toString());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
onTemperatureChange?.(value);
|
||||
},
|
||||
[onTemperatureChange]
|
||||
);
|
||||
|
||||
const handleMaxTokensChange = useCallback(
|
||||
(value: number): void => {
|
||||
setMaxTokens(value);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_MAX_TOKENS, value.toString());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
onMaxTokensChange?.(value);
|
||||
},
|
||||
[onMaxTokensChange]
|
||||
);
|
||||
|
||||
const selectedModelLabel =
|
||||
AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.label ?? selectedModel;
|
||||
|
||||
const characterCount = message.length;
|
||||
const maxCharacters = 4000;
|
||||
const isNearLimit = characterCount > maxCharacters * 0.9;
|
||||
const isOverLimit = characterCount > maxCharacters;
|
||||
const isInputDisabled = disabled ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{/* Model Selector + Params Row */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Model Selector */}
|
||||
<div className="relative" ref={modelDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsModelDropdownOpen((prev) => !prev);
|
||||
setIsParamsOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
||||
style={{
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
backgroundColor: "rgb(var(--surface-1))",
|
||||
color: "rgb(var(--text-secondary))",
|
||||
}}
|
||||
aria-label={`Model: ${selectedModelLabel}. Click to change`}
|
||||
aria-expanded={isModelDropdownOpen}
|
||||
aria-haspopup="listbox"
|
||||
title="Select AI model"
|
||||
>
|
||||
<svg
|
||||
className="h-3 w-3 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83" />
|
||||
</svg>
|
||||
<span>{selectedModelLabel}</span>
|
||||
<svg
|
||||
className={`h-3 w-3 transition-transform ${isModelDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Model Dropdown */}
|
||||
{isModelDropdownOpen && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-1 z-50 min-w-[160px] rounded-lg border shadow-lg"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
}}
|
||||
role="listbox"
|
||||
aria-label="Available models"
|
||||
>
|
||||
{AVAILABLE_MODELS.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
role="option"
|
||||
aria-selected={model.id === selectedModel}
|
||||
onClick={() => {
|
||||
handleModelSelect(model.id);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left text-xs transition-colors first:rounded-t-lg last:rounded-b-lg hover:bg-black/5"
|
||||
style={{
|
||||
color:
|
||||
model.id === selectedModel
|
||||
? "rgb(var(--accent-primary))"
|
||||
: "rgb(var(--text-primary))",
|
||||
fontWeight: model.id === selectedModel ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{model.label}
|
||||
{model.id === selectedModel && (
|
||||
<svg
|
||||
className="inline-block ml-1.5 h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings / Params Icon */}
|
||||
<div className="relative" ref={paramsDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsParamsOpen((prev) => !prev);
|
||||
setIsModelDropdownOpen(false);
|
||||
}}
|
||||
className="flex items-center justify-center rounded-full border p-1 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
||||
style={{
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
backgroundColor: isParamsOpen ? "rgb(var(--surface-2))" : "rgb(var(--surface-1))",
|
||||
color: "rgb(var(--text-muted))",
|
||||
}}
|
||||
aria-label="Chat parameters"
|
||||
aria-expanded={isParamsOpen}
|
||||
aria-haspopup="dialog"
|
||||
title="Configure temperature and max tokens"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Params Popover */}
|
||||
{isParamsOpen && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-1 z-50 w-64 rounded-lg border p-4 shadow-lg"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
}}
|
||||
role="dialog"
|
||||
aria-label="Chat parameters"
|
||||
>
|
||||
<h3
|
||||
className="mb-3 text-xs font-semibold uppercase tracking-wide"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
>
|
||||
Parameters
|
||||
</h3>
|
||||
|
||||
{/* Temperature */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<label
|
||||
className="text-xs font-medium"
|
||||
style={{ color: "rgb(var(--text-secondary))" }}
|
||||
htmlFor="temperature-slider"
|
||||
>
|
||||
Temperature
|
||||
</label>
|
||||
<span
|
||||
className="text-xs font-mono tabular-nums"
|
||||
style={{ color: "rgb(var(--accent-primary))" }}
|
||||
>
|
||||
{temperature.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="temperature-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={(e) => {
|
||||
handleTemperatureChange(parseFloat(e.target.value));
|
||||
}}
|
||||
className="w-full h-1.5 rounded-full appearance-none cursor-pointer"
|
||||
style={{
|
||||
accentColor: "rgb(var(--accent-primary))",
|
||||
backgroundColor: "rgb(var(--surface-2))",
|
||||
}}
|
||||
aria-label={`Temperature: ${temperature.toFixed(1)}`}
|
||||
/>
|
||||
<div
|
||||
className="mt-1 flex justify-between text-[10px]"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
>
|
||||
<span>Precise</span>
|
||||
<span>Creative</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Max Tokens */}
|
||||
<div>
|
||||
<label
|
||||
className="mb-1.5 block text-xs font-medium"
|
||||
style={{ color: "rgb(var(--text-secondary))" }}
|
||||
htmlFor="max-tokens-input"
|
||||
>
|
||||
Max Tokens
|
||||
</label>
|
||||
<input
|
||||
id="max-tokens-input"
|
||||
type="number"
|
||||
min={100}
|
||||
max={32000}
|
||||
step={100}
|
||||
value={maxTokens}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val) && val >= 100 && val <= 32000) {
|
||||
handleMaxTokensChange(val);
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-md border px-2.5 py-1.5 text-xs outline-none focus:ring-2"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-1))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
color: "rgb(var(--text-primary))",
|
||||
}}
|
||||
aria-label="Maximum tokens"
|
||||
/>
|
||||
<p className="mt-1 text-[10px]" style={{ color: "rgb(var(--text-muted))" }}>
|
||||
100 – 32,000
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command Autocomplete Dropdown */}
|
||||
{commandSuggestions.length > 0 && (
|
||||
<div
|
||||
ref={commandDropdownRef}
|
||||
className="rounded-lg border shadow-lg"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
}}
|
||||
role="listbox"
|
||||
aria-label="Command suggestions"
|
||||
data-testid="command-autocomplete"
|
||||
>
|
||||
{commandSuggestions.map((cmd, idx) => (
|
||||
<button
|
||||
key={cmd.name}
|
||||
role="option"
|
||||
aria-selected={idx === highlightedCommandIndex}
|
||||
onClick={() => {
|
||||
acceptCommand(cmd.name);
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left text-sm transition-colors first:rounded-t-lg last:rounded-b-lg"
|
||||
style={{
|
||||
backgroundColor:
|
||||
idx === highlightedCommandIndex
|
||||
? "rgb(var(--accent-primary) / 0.1)"
|
||||
: "transparent",
|
||||
color: "rgb(var(--text-primary))",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-xs font-semibold"
|
||||
style={{ color: "rgb(var(--accent-primary))" }}
|
||||
>
|
||||
{cmd.name}
|
||||
</span>
|
||||
{cmd.aliases && cmd.aliases.length > 0 && (
|
||||
<span className="text-xs" style={{ color: "rgb(var(--text-muted))" }}>
|
||||
({cmd.aliases.join(", ")})
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs ml-auto" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||
{cmd.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Container */}
|
||||
<div
|
||||
className="relative rounded-lg border transition-all duration-150"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: disabled ? "rgb(var(--border-default))" : "rgb(var(--border-strong))",
|
||||
borderColor:
|
||||
isInputDisabled || isStreaming
|
||||
? "rgb(var(--border-default))"
|
||||
: "rgb(var(--border-strong))",
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
@@ -79,8 +607,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
setMessage(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
disabled={disabled}
|
||||
placeholder={isStreaming ? "AI is responding..." : "Type a message..."}
|
||||
disabled={isInputDisabled || isStreaming}
|
||||
rows={1}
|
||||
className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50"
|
||||
style={{
|
||||
@@ -97,28 +625,47 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
aria-describedby="input-help"
|
||||
/>
|
||||
|
||||
{/* Send Button */}
|
||||
{/* Send / Stop Button */}
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={(disabled ?? !message.trim()) || isOverLimit}
|
||||
className="btn-primary btn-sm rounded-md"
|
||||
style={{
|
||||
opacity: disabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
||||
}}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="btn-sm rounded-md flex items-center gap-1.5"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--semantic-error))",
|
||||
color: "white",
|
||||
padding: "0.25rem 0.75rem",
|
||||
}}
|
||||
aria-label="Stop generating"
|
||||
title="Stop generating"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Send</span>
|
||||
</button>
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="1" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline text-sm font-medium">Stop</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isInputDisabled || !message.trim() || isOverLimit}
|
||||
className="btn-primary btn-sm rounded-md"
|
||||
style={{
|
||||
opacity: isInputDisabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
||||
}}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Send</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +675,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
id="input-help"
|
||||
>
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="hidden items-center gap-4 sm:flex">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="kbd">Enter</span>
|
||||
@@ -142,10 +688,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile hint */}
|
||||
<div className="sm:hidden">Tap send or press Enter</div>
|
||||
|
||||
{/* Character Count */}
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
|
||||
@@ -250,6 +250,46 @@ describe("ChatOverlay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("new conversation button", () => {
|
||||
it("should render the new conversation button when chat is open", async () => {
|
||||
const { useChatOverlay } = await import("../../hooks/useChatOverlay");
|
||||
vi.mocked(useChatOverlay).mockReturnValue({
|
||||
isOpen: true,
|
||||
isMinimized: false,
|
||||
open: mockOpen,
|
||||
close: mockClose,
|
||||
minimize: mockMinimize,
|
||||
expand: mockExpand,
|
||||
toggle: mockToggle,
|
||||
toggleMinimize: mockToggleMinimize,
|
||||
});
|
||||
|
||||
render(<ChatOverlay />);
|
||||
|
||||
const newConvBtn = screen.getByRole("button", { name: /new conversation/i });
|
||||
expect(newConvBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it("should have a tooltip on the new conversation button", async () => {
|
||||
const { useChatOverlay } = await import("../../hooks/useChatOverlay");
|
||||
vi.mocked(useChatOverlay).mockReturnValue({
|
||||
isOpen: true,
|
||||
isMinimized: false,
|
||||
open: mockOpen,
|
||||
close: mockClose,
|
||||
minimize: mockMinimize,
|
||||
expand: mockExpand,
|
||||
toggle: mockToggle,
|
||||
toggleMinimize: mockToggleMinimize,
|
||||
});
|
||||
|
||||
render(<ChatOverlay />);
|
||||
|
||||
const newConvBtn = screen.getByRole("button", { name: /new conversation/i });
|
||||
expect(newConvBtn.getAttribute("title")).toContain("Cmd+N");
|
||||
});
|
||||
});
|
||||
|
||||
describe("responsive design", () => {
|
||||
it("should render as a sidebar on desktop", () => {
|
||||
render(<ChatOverlay />);
|
||||
|
||||
@@ -164,6 +164,27 @@ export function ChatOverlay(): React.JSX.Element {
|
||||
|
||||
{/* Header Controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* New Conversation Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
chatRef.current?.startNewConversation(null);
|
||||
}}
|
||||
className="rounded p-1.5 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
||||
aria-label="New conversation"
|
||||
title="New conversation (Cmd+N)"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
style={{ color: "rgb(var(--text-secondary))" }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Minimize Button */}
|
||||
<button
|
||||
onClick={minimize}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Message } from "@/hooks/useChat";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
isStreaming?: boolean;
|
||||
streamingMessageId?: string;
|
||||
loadingQuip?: string | null;
|
||||
}
|
||||
|
||||
@@ -14,7 +16,6 @@ interface MessageListProps {
|
||||
* Extracts <thinking>...</thinking> or <think>...</think> blocks.
|
||||
*/
|
||||
function parseThinking(content: string): { thinking: string | null; response: string } {
|
||||
// Match <thinking>...</thinking> or <think>...</think> blocks
|
||||
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
|
||||
const matches = content.match(thinkingRegex);
|
||||
|
||||
@@ -22,14 +23,12 @@ function parseThinking(content: string): { thinking: string | null; response: st
|
||||
return { thinking: null, response: content };
|
||||
}
|
||||
|
||||
// Extract thinking content
|
||||
let thinking = "";
|
||||
for (const match of matches) {
|
||||
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
|
||||
thinking += innerContent.trim() + "\n";
|
||||
}
|
||||
|
||||
// Remove thinking blocks from response
|
||||
const response = content.replace(thinkingRegex, "").trim();
|
||||
|
||||
const trimmedThinking = thinking.trim();
|
||||
@@ -42,25 +41,47 @@ function parseThinking(content: string): { thinking: string | null; response: st
|
||||
export function MessageList({
|
||||
messages,
|
||||
isLoading,
|
||||
isStreaming = false,
|
||||
streamingMessageId,
|
||||
loadingQuip,
|
||||
}: MessageListProps): React.JSX.Element {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when messages change or streaming tokens arrive
|
||||
useEffect(() => {
|
||||
if (isStreaming || isLoading) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [messages, isStreaming, isLoading]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6" role="log" aria-label="Chat messages">
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={isStreaming && message.id === streamingMessageId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isLoading && <LoadingIndicator {...(loadingQuip != null && { quip: loadingQuip })} />}
|
||||
{isLoading && !isStreaming && (
|
||||
<LoadingIndicator {...(loadingQuip != null && { quip: loadingQuip })} />
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
function MessageBubble({ message, isStreaming = false }: MessageBubbleProps): React.JSX.Element {
|
||||
const isUser = message.role === "user";
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||
|
||||
// Parse thinking from content (or use pre-parsed thinking field)
|
||||
const { thinking, response } = message.thinking
|
||||
? { thinking: message.thinking, response: message.content }
|
||||
: parseThinking(message.content);
|
||||
@@ -73,7 +94,6 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
// Silently fail - clipboard copy is non-critical
|
||||
void err;
|
||||
}
|
||||
}, [response]);
|
||||
@@ -106,8 +126,21 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
||||
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||
{isUser ? "You" : "AI Assistant"}
|
||||
</span>
|
||||
{/* Streaming indicator in header */}
|
||||
{!isUser && isStreaming && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--accent-primary) / 0.15)",
|
||||
color: "rgb(var(--accent-primary))",
|
||||
}}
|
||||
aria-label="Streaming"
|
||||
>
|
||||
streaming
|
||||
</span>
|
||||
)}
|
||||
{/* Model indicator for assistant messages */}
|
||||
{!isUser && message.model && (
|
||||
{!isUser && message.model && !isStreaming && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
style={{
|
||||
@@ -200,43 +233,54 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
|
||||
border: isUser ? "none" : "1px solid rgb(var(--border-default))",
|
||||
}}
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{response}</p>
|
||||
|
||||
{/* Copy Button - appears on hover */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
|
||||
}}
|
||||
aria-label={copied ? "Copied!" : "Copy message"}
|
||||
title={copied ? "Copied!" : "Copy to clipboard"}
|
||||
>
|
||||
{copied ? (
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{response}
|
||||
{/* Blinking cursor during streaming */}
|
||||
{isStreaming && !isUser && (
|
||||
<span
|
||||
className="streaming-cursor inline-block ml-0.5 align-middle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
{/* Copy Button - hidden while streaming */}
|
||||
{!isStreaming && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
|
||||
}}
|
||||
aria-label={copied ? "Copied!" : "Copy message"}
|
||||
title={copied ? "Copied!" : "Copy to clipboard"}
|
||||
>
|
||||
{copied ? (
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,17 @@
|
||||
*/
|
||||
|
||||
export { Chat, type ChatRef, type NewConversationData } from "./Chat";
|
||||
export { ChatInput } from "./ChatInput";
|
||||
export {
|
||||
ChatInput,
|
||||
AVAILABLE_MODELS,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_TEMPERATURE,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
} from "./ChatInput";
|
||||
export type { ModelId } from "./ChatInput";
|
||||
export { MessageList } from "./MessageList";
|
||||
export { ConversationSidebar, type ConversationSidebarRef } from "./ConversationSidebar";
|
||||
export { BackendStatusBanner } from "./BackendStatusBanner";
|
||||
export { ChatOverlay } from "./ChatOverlay";
|
||||
export { ChatEmptyState } from "./ChatEmptyState";
|
||||
export type { Message } from "@/hooks/useChat";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -254,7 +254,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
badge: { label: "live", pulse: true },
|
||||
},
|
||||
{
|
||||
href: "#terminal",
|
||||
href: "/terminal",
|
||||
label: "Terminal",
|
||||
icon: <IconTerminal />,
|
||||
},
|
||||
|
||||
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";
|
||||
content: string;
|
||||
}
|
||||
/**
|
||||
* TerminalPanel
|
||||
*
|
||||
* Multi-tab terminal panel. Manages multiple PTY sessions via useTerminalSessions,
|
||||
* rendering one XTerminal per session and keeping all instances mounted (for
|
||||
* scrollback preservation) while switching visibility with display:none.
|
||||
*
|
||||
* Also renders read-only agent output tabs from the orchestrator SSE stream
|
||||
* via useAgentStream. Agent tabs are automatically added when agents are active
|
||||
* and can be dismissed when completed or errored.
|
||||
*
|
||||
* Features:
|
||||
* - "+" button to open a new terminal tab
|
||||
* - Per-tab close button (terminal) / dismiss button (agent)
|
||||
* - Double-click tab label for inline rename (terminal tabs only)
|
||||
* - Auto-creates the first terminal session on connect
|
||||
* - Connection error state
|
||||
* - Agent tabs: read-only, auto-appear, dismissable
|
||||
*/
|
||||
|
||||
export interface TerminalTab {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { ReactElement, CSSProperties, KeyboardEvent } from "react";
|
||||
import { XTerminal } from "./XTerminal";
|
||||
import { AgentTerminal } from "./AgentTerminal";
|
||||
import { useTerminalSessions } from "@/hooks/useTerminalSessions";
|
||||
import { useAgentStream } from "@/hooks/useAgentStream";
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
export interface TerminalPanelProps {
|
||||
/** Whether the panel is visible */
|
||||
open: boolean;
|
||||
/** Called when the user closes the panel */
|
||||
onClose: () => void;
|
||||
tabs?: TerminalTab[];
|
||||
activeTab?: string;
|
||||
onTabChange?: (id: string) => void;
|
||||
lines?: TerminalLine[];
|
||||
/** Authentication token for the WebSocket connection */
|
||||
token?: string;
|
||||
/** Optional CSS class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const defaultTabs: TerminalTab[] = [
|
||||
{ id: "main", label: "main" },
|
||||
{ id: "build", label: "build" },
|
||||
{ id: "logs", label: "logs" },
|
||||
];
|
||||
|
||||
const blinkKeyframes = `
|
||||
@keyframes ms-terminal-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`;
|
||||
|
||||
let blinkStyleInjected = false;
|
||||
|
||||
function ensureBlinkStyle(): void {
|
||||
if (blinkStyleInjected || typeof document === "undefined") return;
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.textContent = blinkKeyframes;
|
||||
document.head.appendChild(styleEl);
|
||||
blinkStyleInjected = true;
|
||||
}
|
||||
|
||||
function getLineColor(type: TerminalLine["type"]): string {
|
||||
switch (type) {
|
||||
case "prompt":
|
||||
return "var(--success)";
|
||||
case "command":
|
||||
return "var(--text-2)";
|
||||
case "output":
|
||||
return "var(--muted)";
|
||||
case "error":
|
||||
return "var(--danger)";
|
||||
case "warning":
|
||||
return "var(--warn)";
|
||||
case "success":
|
||||
return "var(--success)";
|
||||
default:
|
||||
return "var(--muted)";
|
||||
}
|
||||
}
|
||||
// ==========================================
|
||||
// Component
|
||||
// ==========================================
|
||||
|
||||
export function TerminalPanel({
|
||||
open,
|
||||
onClose,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
lines = [],
|
||||
token = "",
|
||||
className = "",
|
||||
}: TerminalPanelProps): ReactElement {
|
||||
ensureBlinkStyle();
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
isConnected,
|
||||
connectionError,
|
||||
createSession,
|
||||
closeSession,
|
||||
renameSession,
|
||||
setActiveSession,
|
||||
sendInput,
|
||||
resize,
|
||||
registerOutputCallback,
|
||||
} = useTerminalSessions({ token });
|
||||
|
||||
const resolvedTabs = tabs ?? defaultTabs;
|
||||
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
|
||||
// ==========================================
|
||||
// Agent stream
|
||||
// ==========================================
|
||||
|
||||
const { agents, dismissAgent } = useAgentStream();
|
||||
|
||||
// ==========================================
|
||||
// Active tab state (terminal session OR agent)
|
||||
// "terminal:<sessionId>" or "agent:<agentId>"
|
||||
// ==========================================
|
||||
|
||||
type TabId = string; // prefix-qualified: "terminal:<id>" or "agent:<id>"
|
||||
|
||||
const [activeTabId, setActiveTabId] = useState<TabId | null>(null);
|
||||
|
||||
// Sync activeTabId with the terminal session activeSessionId when no agent tab is selected
|
||||
useEffect(() => {
|
||||
setActiveTabId((prev) => {
|
||||
// If an agent tab is active, don't clobber it
|
||||
if (prev?.startsWith("agent:")) return prev;
|
||||
// Reflect active terminal session
|
||||
if (activeSessionId !== null) return `terminal:${activeSessionId}`;
|
||||
return prev;
|
||||
});
|
||||
}, [activeSessionId]);
|
||||
|
||||
// If the active agent tab is dismissed, fall back to the terminal session
|
||||
useEffect(() => {
|
||||
if (activeTabId?.startsWith("agent:")) {
|
||||
const agentId = activeTabId.slice("agent:".length);
|
||||
if (!agents.has(agentId)) {
|
||||
setActiveTabId(activeSessionId !== null ? `terminal:${activeSessionId}` : null);
|
||||
}
|
||||
}
|
||||
}, [agents, activeTabId, activeSessionId]);
|
||||
|
||||
// ==========================================
|
||||
// Inline rename state
|
||||
// ==========================================
|
||||
|
||||
const [editingTabId, setEditingTabId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus the rename input when editing starts
|
||||
useEffect(() => {
|
||||
if (editingTabId !== null) {
|
||||
editInputRef.current?.select();
|
||||
}
|
||||
}, [editingTabId]);
|
||||
|
||||
// ==========================================
|
||||
// Auto-create first session on connect
|
||||
// ==========================================
|
||||
|
||||
useEffect(() => {
|
||||
if (open && isConnected && sessions.size === 0) {
|
||||
createSession({ name: "Terminal 1" });
|
||||
}
|
||||
}, [open, isConnected, sessions.size, createSession]);
|
||||
|
||||
// ==========================================
|
||||
// Tab rename helpers
|
||||
// ==========================================
|
||||
|
||||
const commitRename = useCallback((): void => {
|
||||
if (editingTabId !== null) {
|
||||
const trimmed = editingName.trim();
|
||||
if (trimmed.length > 0) {
|
||||
renameSession(editingTabId, trimmed);
|
||||
}
|
||||
setEditingTabId(null);
|
||||
setEditingName("");
|
||||
}
|
||||
}, [editingTabId, editingName, renameSession]);
|
||||
|
||||
const handleTabDoubleClick = useCallback((sessionId: string, currentName: string): void => {
|
||||
setEditingTabId(sessionId);
|
||||
setEditingName(currentName);
|
||||
}, []);
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === "Enter") {
|
||||
commitRename();
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingTabId(null);
|
||||
setEditingName("");
|
||||
}
|
||||
},
|
||||
[commitRename]
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// Session control helpers
|
||||
// ==========================================
|
||||
|
||||
const handleCreateTab = useCallback((): void => {
|
||||
const tabNumber = sessions.size + 1;
|
||||
createSession({ name: `Terminal ${tabNumber.toString()}` });
|
||||
}, [sessions.size, createSession]);
|
||||
|
||||
const handleCloseTab = useCallback(
|
||||
(sessionId: string): void => {
|
||||
closeSession(sessionId);
|
||||
},
|
||||
[closeSession]
|
||||
);
|
||||
|
||||
const handleRestart = useCallback(
|
||||
(sessionId: string, name: string): void => {
|
||||
closeSession(sessionId);
|
||||
createSession({ name });
|
||||
},
|
||||
[closeSession, createSession]
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// Styles
|
||||
// ==========================================
|
||||
|
||||
const panelStyle: CSSProperties = {
|
||||
background: "var(--bg-deep)",
|
||||
@@ -99,33 +210,40 @@ export function TerminalPanel({
|
||||
const tabBarStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const actionsStyle: CSSProperties = {
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
alignItems: "center",
|
||||
};
|
||||
|
||||
const bodyStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "10px 16px",
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: "0.78rem",
|
||||
lineHeight: 1.6,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
};
|
||||
|
||||
const cursorStyle: CSSProperties = {
|
||||
display: "inline-block",
|
||||
width: 7,
|
||||
height: 14,
|
||||
background: "var(--success)",
|
||||
marginLeft: 2,
|
||||
animation: "ms-terminal-blink 1s step-end infinite",
|
||||
verticalAlign: "text-bottom",
|
||||
// ==========================================
|
||||
// Agent status dot color
|
||||
// ==========================================
|
||||
|
||||
const agentDotColor = (status: string): string => {
|
||||
if (status === "running" || status === "spawning") return "var(--success)";
|
||||
if (status === "error") return "var(--danger)";
|
||||
return "var(--muted)";
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Render
|
||||
// ==========================================
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
@@ -138,50 +256,315 @@ export function TerminalPanel({
|
||||
<div style={headerStyle}>
|
||||
{/* Tab bar */}
|
||||
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
||||
{resolvedTabs.map((tab) => {
|
||||
const isActive = tab.id === resolvedActiveTab;
|
||||
{/* ---- Terminal session tabs ---- */}
|
||||
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
|
||||
const tabKey = `terminal:${sessionId}`;
|
||||
const isActive = tabKey === activeTabId;
|
||||
const isEditing = sessionId === editingTabId;
|
||||
|
||||
const tabStyle: CSSProperties = {
|
||||
padding: "3px 10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "3px 6px 3px 10px",
|
||||
borderRadius: 4,
|
||||
fontSize: "0.75rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: isActive ? "var(--success)" : "var(--muted)",
|
||||
cursor: "pointer",
|
||||
background: isActive ? "var(--surface)" : "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
style={tabStyle}
|
||||
onClick={(): void => {
|
||||
onTabChange?.(tab.id);
|
||||
}}
|
||||
onMouseEnter={(e): void => {
|
||||
if (!isActive) {
|
||||
<div key={tabKey} style={tabStyle}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
value={editingName}
|
||||
onChange={(e): void => {
|
||||
setEditingName(e.target.value);
|
||||
}}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
data-testid="tab-rename-input"
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "1px solid var(--primary)",
|
||||
borderRadius: 2,
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--text)",
|
||||
width: `${Math.max(editingName.length, 4).toString()}ch`,
|
||||
padding: "0 2px",
|
||||
}}
|
||||
aria-label="Rename terminal tab"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
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.color = "var(--text-2)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||
}
|
||||
}}
|
||||
}}
|
||||
>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M1 1L7 7M7 1L1 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* New tab button */}
|
||||
<button
|
||||
aria-label="New terminal tab"
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onClick={handleCreateTab}
|
||||
onMouseEnter={(e): void => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
{/* Plus icon */}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6 1V11M1 6H11"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* ---- Agent section divider (only when agents exist) ---- */}
|
||||
{agents.size > 0 && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 1,
|
||||
height: 16,
|
||||
background: "var(--border)",
|
||||
marginLeft: 6,
|
||||
marginRight: 4,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ---- Agent tabs ---- */}
|
||||
{[...agents.entries()].map(([agentId, agentSession]) => {
|
||||
const tabKey = `agent:${agentId}`;
|
||||
const isActive = tabKey === activeTabId;
|
||||
|
||||
const canDismiss =
|
||||
agentSession.status === "completed" || agentSession.status === "error";
|
||||
|
||||
const agentTabStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "3px 6px 3px 8px",
|
||||
borderRadius: 4,
|
||||
fontSize: "0.75rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: isActive ? "var(--text)" : "var(--muted)",
|
||||
background: isActive ? "var(--surface)" : "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabKey}
|
||||
style={agentTabStyle}
|
||||
data-testid="agent-tab"
|
||||
data-agent-id={agentId}
|
||||
data-agent-status={agentSession.status}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: agentDotColor(agentSession.status),
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Agent tab button — read-only, no rename */}
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: "0.75rem",
|
||||
color: isActive ? "var(--text)" : "var(--muted)",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
maxWidth: 100,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onClick={(): void => {
|
||||
setActiveTabId(tabKey);
|
||||
}}
|
||||
onMouseEnter={(e): void => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||
}
|
||||
}}
|
||||
aria-label={`Agent: ${agentSession.agentType}`}
|
||||
>
|
||||
{agentSession.agentType}
|
||||
</button>
|
||||
|
||||
{/* Dismiss button — only for completed/error agents */}
|
||||
{canDismiss && (
|
||||
<button
|
||||
aria-label={`Dismiss ${agentSession.agentType} agent`}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 3,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onClick={(): void => {
|
||||
dismissAgent(agentId);
|
||||
}}
|
||||
onMouseEnter={(e): void => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M1 1L7 7M7 1L1 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={actionsStyle}>
|
||||
{/* Close panel button */}
|
||||
<button
|
||||
aria-label="Close terminal"
|
||||
style={{
|
||||
@@ -208,7 +591,7 @@ export function TerminalPanel({
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
{/* Close icon — simple X using SVG */}
|
||||
{/* Close icon */}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M1 1L11 11M11 1L1 11"
|
||||
@@ -221,34 +604,90 @@ export function TerminalPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
|
||||
{lines.map((line, index) => {
|
||||
const isLast = index === lines.length - 1;
|
||||
const lineStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
};
|
||||
const contentStyle: CSSProperties = {
|
||||
color: getLineColor(line.type),
|
||||
{/* Connection error banner */}
|
||||
{connectionError !== null && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
padding: "4px 16px",
|
||||
fontSize: "0.75rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--danger)",
|
||||
backgroundColor: "var(--bg-deep)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Connection error: {connectionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal body — keep all XTerminal instances mounted for scrollback */}
|
||||
<div style={bodyStyle}>
|
||||
{/* ---- Terminal session panels ---- */}
|
||||
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
|
||||
const tabKey = `terminal:${sessionId}`;
|
||||
const isActive = tabKey === activeTabId;
|
||||
const termStyle: CSSProperties = {
|
||||
display: isActive ? "flex" : "none",
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={index} style={lineStyle}>
|
||||
<span style={contentStyle}>
|
||||
{line.content}
|
||||
{isLast && <span aria-hidden="true" style={cursorStyle} />}
|
||||
</span>
|
||||
<div key={tabKey} style={termStyle}>
|
||||
<XTerminal
|
||||
sessionId={sessionId}
|
||||
sendInput={sendInput}
|
||||
resize={resize}
|
||||
closeSession={closeSession}
|
||||
registerOutputCallback={registerOutputCallback}
|
||||
isConnected={isConnected}
|
||||
sessionStatus={sessionInfo.status}
|
||||
{...(sessionInfo.exitCode !== undefined ? { exitCode: sessionInfo.exitCode } : {})}
|
||||
isVisible={isActive && open}
|
||||
onRestart={(): void => {
|
||||
handleRestart(sessionId, sessionInfo.name);
|
||||
}}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Show cursor even when no lines */}
|
||||
{lines.length === 0 && (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<span style={{ color: "var(--success)" }}>
|
||||
<span aria-hidden="true" style={cursorStyle} />
|
||||
</span>
|
||||
{/* ---- Agent session panels ---- */}
|
||||
{[...agents.entries()].map(([agentId, agentSession]) => {
|
||||
const tabKey = `agent:${agentId}`;
|
||||
const isActive = tabKey === activeTabId;
|
||||
const agentPanelStyle: CSSProperties = {
|
||||
display: isActive ? "flex" : "none",
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={tabKey} style={agentPanelStyle}>
|
||||
<AgentTerminal agent={agentSession} style={{ flex: 1, minHeight: 0 }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty state — show only when no terminal sessions AND no agent sessions */}
|
||||
{sessions.size === 0 && agents.size === 0 && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.75rem",
|
||||
fontFamily: "var(--mono)",
|
||||
}}
|
||||
>
|
||||
{isConnected ? "Creating terminal..." : (connectionError ?? "Connecting...")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
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 type { XTerminalProps } from "./XTerminal";
|
||||
export { XTerminal } from "./XTerminal";
|
||||
export type { AgentTerminalProps } from "./AgentTerminal";
|
||||
export { AgentTerminal } from "./AgentTerminal";
|
||||
|
||||
@@ -91,7 +91,7 @@ export function SelectTrigger({
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
className={`flex h-10 w-full items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ${className}`}
|
||||
className={`flex h-10 w-full items-center justify-between rounded-md border border-border bg-bg px-3 py-2 text-sm text-text ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -110,7 +110,7 @@ export function SelectContent({ children }: SelectContentProps): React.JSX.Eleme
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-surface shadow-md">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -122,7 +122,7 @@ export function SelectItem({ value, children }: SelectItemProps): React.JSX.Elem
|
||||
return (
|
||||
<div
|
||||
onClick={() => onValueChange?.(value)}
|
||||
className="cursor-pointer px-3 py-2 text-sm hover:bg-gray-100"
|
||||
className="cursor-pointer px-3 py-2 text-sm text-text hover:bg-surface-2"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useState, useEffect } from "react";
|
||||
import { FolderOpen, Bot, Activity, Clock, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
import { apiPost } from "@/lib/api/client";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
interface ActiveProject {
|
||||
id: string;
|
||||
@@ -34,6 +35,7 @@ interface AgentSession {
|
||||
}
|
||||
|
||||
export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||
const workspaceId = useWorkspaceId();
|
||||
const [projects, setProjects] = useState<ActiveProject[]>([]);
|
||||
const [agentSessions, setAgentSessions] = useState<AgentSession[]>([]);
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||||
@@ -48,7 +50,11 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
try {
|
||||
setProjectsError(null);
|
||||
// Use API client to ensure CSRF token is included
|
||||
const data = await apiPost<ActiveProject[]>("/api/widgets/data/active-projects");
|
||||
const data = await apiPost<ActiveProject[]>(
|
||||
"/api/widgets/data/active-projects",
|
||||
undefined,
|
||||
workspaceId ?? undefined
|
||||
);
|
||||
setProjects(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch active projects:", error);
|
||||
@@ -67,7 +73,7 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}, [workspaceId]);
|
||||
|
||||
// Fetch agent chains
|
||||
useEffect(() => {
|
||||
@@ -75,7 +81,11 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
try {
|
||||
setAgentsError(null);
|
||||
// Use API client to ensure CSRF token is included
|
||||
const data = await apiPost<AgentSession[]>("/api/widgets/data/agent-chains");
|
||||
const data = await apiPost<AgentSession[]>(
|
||||
"/api/widgets/data/agent-chains",
|
||||
undefined,
|
||||
workspaceId ?? undefined
|
||||
);
|
||||
setAgentSessions(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agent sessions:", error);
|
||||
@@ -94,7 +104,7 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}, [workspaceId]);
|
||||
|
||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||
const statusUpper = status.toUpperCase();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { WidgetConfigDialog } from "../WidgetConfigDialog";
|
||||
|
||||
describe("WidgetConfigDialog", (): void => {
|
||||
const defaultProps = {
|
||||
widgetId: "TasksWidget-abc123",
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render nothing when closed", (): void => {
|
||||
const { container } = render(<WidgetConfigDialog {...defaultProps} open={false} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("should render dialog when open", (): void => {
|
||||
render(<WidgetConfigDialog {...defaultProps} />);
|
||||
expect(screen.getByRole("dialog", { name: "Widget Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show widget name in header", (): void => {
|
||||
render(<WidgetConfigDialog {...defaultProps} />);
|
||||
// TasksWidget is registered with displayName "Tasks"
|
||||
expect(screen.getByText(/Tasks.*Settings/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show placeholder message when no config schema", (): void => {
|
||||
render(<WidgetConfigDialog {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText("No configuration options available for this widget yet.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClose when close button is clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(<WidgetConfigDialog {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Close"));
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onClose when footer Close button is clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(<WidgetConfigDialog {...defaultProps} />);
|
||||
|
||||
// Footer has a "Close" text button
|
||||
await user.click(screen.getByText("Close"));
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle unknown widget type gracefully", (): void => {
|
||||
render(<WidgetConfigDialog {...defaultProps} widgetId="UnknownWidget-xyz" />);
|
||||
// Should show fallback "Widget Settings" when type is not in registry
|
||||
expect(screen.getByText("Widget Settings")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
101
apps/web/src/components/widgets/__tests__/WidgetPicker.test.tsx
Normal file
101
apps/web/src/components/widgets/__tests__/WidgetPicker.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { WidgetPicker } from "../WidgetPicker";
|
||||
import type { WidgetPlacement } from "@mosaic/shared";
|
||||
|
||||
describe("WidgetPicker", (): void => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onAddWidget: vi.fn(),
|
||||
currentLayout: [] as WidgetPlacement[],
|
||||
};
|
||||
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render nothing when closed", (): void => {
|
||||
const { container } = render(<WidgetPicker {...defaultProps} open={false} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("should render dialog when open", (): void => {
|
||||
render(<WidgetPicker {...defaultProps} />);
|
||||
expect(screen.getByRole("dialog", { name: "Add Widget" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Add Widget heading", (): void => {
|
||||
render(<WidgetPicker {...defaultProps} />);
|
||||
expect(screen.getByText("Add Widget")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render search input", (): void => {
|
||||
render(<WidgetPicker {...defaultProps} />);
|
||||
expect(screen.getByPlaceholderText("Search widgets...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display available widgets", (): void => {
|
||||
render(<WidgetPicker {...defaultProps} />);
|
||||
// Widget registry has multiple widgets; at least one Add button should appear
|
||||
const addButtons = screen.getAllByText("Add");
|
||||
expect(addButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should filter widgets by search text", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(<WidgetPicker {...defaultProps} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText("Search widgets...");
|
||||
// Type a search term that won't match any widget
|
||||
await user.type(searchInput, "zzz-nonexistent-widget-zzz");
|
||||
|
||||
expect(screen.getByText("No widgets found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onAddWidget when Add is clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const onAdd = vi.fn();
|
||||
render(<WidgetPicker {...defaultProps} onAddWidget={onAdd} />);
|
||||
|
||||
const addButtons = screen.getAllByText("Add");
|
||||
await user.click(addButtons[0]!);
|
||||
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
const placement = onAdd.mock.calls[0]![0] as WidgetPlacement;
|
||||
expect(placement).toHaveProperty("i");
|
||||
expect(placement).toHaveProperty("x");
|
||||
expect(placement).toHaveProperty("y");
|
||||
expect(placement).toHaveProperty("w");
|
||||
expect(placement).toHaveProperty("h");
|
||||
});
|
||||
|
||||
it("should call onClose when close button is clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(<WidgetPicker {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Close"));
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should place new widgets after existing layout items", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const onAdd = vi.fn();
|
||||
const existingLayout: WidgetPlacement[] = [
|
||||
{ i: "test-1", x: 0, y: 0, w: 6, h: 3 },
|
||||
{ i: "test-2", x: 0, y: 3, w: 6, h: 2 },
|
||||
];
|
||||
render(<WidgetPicker {...defaultProps} onAddWidget={onAdd} currentLayout={existingLayout} />);
|
||||
|
||||
const addButtons = screen.getAllByText("Add");
|
||||
await user.click(addButtons[0]!);
|
||||
|
||||
const placement = onAdd.mock.calls[0]![0] as WidgetPlacement;
|
||||
// Should be placed at y=5 (3+2) to avoid overlap
|
||||
expect(placement.y).toBe(5);
|
||||
});
|
||||
});
|
||||
542
apps/web/src/hooks/__tests__/useAgentStream.test.ts
Normal file
542
apps/web/src/hooks/__tests__/useAgentStream.test.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* @file useAgentStream.test.ts
|
||||
* @description Unit tests for the useAgentStream hook
|
||||
*
|
||||
* Tests cover:
|
||||
* - SSE event parsing (agent:spawned, agent:output, agent:completed, agent:error)
|
||||
* - Agent lifecycle state transitions
|
||||
* - Auto-reconnect behavior on connection loss
|
||||
* - Cleanup on unmount
|
||||
* - Dismiss agent
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useAgentStream } from "../useAgentStream";
|
||||
|
||||
// ==========================================
|
||||
// Mock EventSource
|
||||
// ==========================================
|
||||
|
||||
interface MockEventSourceInstance {
|
||||
url: string;
|
||||
onopen: (() => void) | null;
|
||||
onerror: ((event: Event) => void) | null;
|
||||
onmessage: ((event: MessageEvent) => void) | null;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
dispatchEvent: (type: string, data: string) => void;
|
||||
_listeners: Map<string, ((event: MessageEvent<string>) => void)[]>;
|
||||
readyState: number;
|
||||
}
|
||||
|
||||
let mockEventSourceInstances: MockEventSourceInstance[] = [];
|
||||
|
||||
const MockEventSource = vi.fn(function (this: MockEventSourceInstance, url: string): void {
|
||||
this.url = url;
|
||||
this.onopen = null;
|
||||
this.onerror = null;
|
||||
this.onmessage = null;
|
||||
this.close = vi.fn();
|
||||
this.readyState = 0;
|
||||
this._listeners = new Map();
|
||||
|
||||
this.addEventListener = vi.fn(
|
||||
(type: string, handler: (event: MessageEvent<string>) => void): void => {
|
||||
if (!this._listeners.has(type)) {
|
||||
this._listeners.set(type, []);
|
||||
}
|
||||
const list = this._listeners.get(type);
|
||||
if (list) list.push(handler);
|
||||
}
|
||||
);
|
||||
|
||||
this.dispatchEvent = (type: string, data: string): void => {
|
||||
const handlers = this._listeners.get(type) ?? [];
|
||||
const event = new MessageEvent(type, { data });
|
||||
for (const handler of handlers) {
|
||||
handler(event);
|
||||
}
|
||||
};
|
||||
|
||||
mockEventSourceInstances.push(this);
|
||||
});
|
||||
|
||||
// Add static constants
|
||||
Object.assign(MockEventSource, {
|
||||
CONNECTING: 0,
|
||||
OPEN: 1,
|
||||
CLOSED: 2,
|
||||
});
|
||||
|
||||
vi.stubGlobal("EventSource", MockEventSource);
|
||||
|
||||
// ==========================================
|
||||
// Helpers
|
||||
// ==========================================
|
||||
|
||||
function getLatestES(): MockEventSourceInstance {
|
||||
const es = mockEventSourceInstances[mockEventSourceInstances.length - 1];
|
||||
if (!es) throw new Error("No EventSource instance created");
|
||||
return es;
|
||||
}
|
||||
|
||||
function triggerOpen(): void {
|
||||
const es = getLatestES();
|
||||
if (es.onopen) es.onopen();
|
||||
}
|
||||
|
||||
function triggerError(): void {
|
||||
const es = getLatestES();
|
||||
if (es.onerror) es.onerror(new Event("error"));
|
||||
}
|
||||
|
||||
function emitEvent(type: string, data: unknown): void {
|
||||
const es = getLatestES();
|
||||
es.dispatchEvent(type, JSON.stringify(data));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Tests
|
||||
// ==========================================
|
||||
|
||||
describe("useAgentStream", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mockEventSourceInstances = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Initialization
|
||||
// ==========================================
|
||||
|
||||
describe("initialization", () => {
|
||||
it("creates an EventSource connecting to /api/orchestrator/events", () => {
|
||||
renderHook(() => useAgentStream());
|
||||
expect(MockEventSource).toHaveBeenCalledWith("/api/orchestrator/events");
|
||||
});
|
||||
|
||||
it("starts with isConnected=false before onopen fires", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("starts with an empty agents map", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
expect(result.current.agents.size).toBe(0);
|
||||
});
|
||||
|
||||
it("sets isConnected=true when EventSource opens", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
});
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("clears connectionError when EventSource opens", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
// Trigger an error first to set connectionError
|
||||
act(() => {
|
||||
triggerError();
|
||||
});
|
||||
|
||||
// Start a fresh reconnect and open it
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
});
|
||||
|
||||
expect(result.current.connectionError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// SSE event: agent:spawned
|
||||
// ==========================================
|
||||
|
||||
describe("agent:spawned event", () => {
|
||||
it("adds an agent with status=spawning", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker", jobId: "job-abc" });
|
||||
});
|
||||
|
||||
expect(result.current.agents.has("agent-1")).toBe(true);
|
||||
expect(result.current.agents.get("agent-1")?.status).toBe("spawning");
|
||||
});
|
||||
|
||||
it("sets agentType from the type field", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "planner" });
|
||||
});
|
||||
|
||||
expect(result.current.agents.get("agent-1")?.agentType).toBe("planner");
|
||||
});
|
||||
|
||||
it("defaults agentType to 'agent' when type is missing", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-2" });
|
||||
});
|
||||
|
||||
expect(result.current.agents.get("agent-2")?.agentType).toBe("agent");
|
||||
});
|
||||
|
||||
it("stores jobId when present", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-3", type: "worker", jobId: "job-xyz" });
|
||||
});
|
||||
|
||||
expect(result.current.agents.get("agent-3")?.jobId).toBe("job-xyz");
|
||||
});
|
||||
|
||||
it("starts with empty outputLines", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
||||
});
|
||||
|
||||
expect(result.current.agents.get("agent-1")?.outputLines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// SSE event: agent:output
|
||||
// ==========================================
|
||||
|
||||
describe("agent:output event", () => {
|
||||
it("appends output to the agent's outputLines", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
||||
emitEvent("agent:output", { agentId: "agent-1", data: "Hello world\n" });
|
||||
});
|
||||
|
||||
expect(result.current.agents.get("agent-1")?.outputLines).toContain("Hello world\n");
|
||||
});
|
||||
|
||||
it("transitions status from spawning to running on first output", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
||||
emitEvent("agent:output", { agentId: "agent-1", data: "Starting...\n" });
|
||||
});
|
||||
|
||||
expect(result.current.agents.get("agent-1")?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("accumulates multiple output lines", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
||||
emitEvent("agent:output", { agentId: "agent-1", data: "Line 1\n" });
|
||||
emitEvent("agent:output", { agentId: "agent-1", data: "Line 2\n" });
|
||||
emitEvent("agent:output", { agentId: "agent-1", data: "Line 3\n" });
|
||||
});
|
||||
|
||||
const lines = result.current.agents.get("agent-1")?.outputLines ?? [];
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines[0]).toBe("Line 1\n");
|
||||
expect(lines[1]).toBe("Line 2\n");
|
||||
expect(lines[2]).toBe("Line 3\n");
|
||||
});
|
||||
|
||||
it("creates a new agent entry if output arrives before spawned event", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:output", { agentId: "unknown-agent", data: "Surprise output\n" });
|
||||
});
|
||||
|
||||
expect(result.current.agents.has("unknown-agent")).toBe(true);
|
||||
expect(result.current.agents.get("unknown-agent")?.status).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// SSE event: agent:completed
|
||||
// ==========================================
|
||||
|
||||
describe("agent:completed event", () => {
|
||||
it("sets status to completed", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
||||
emitEvent("agent:output", { agentId: "agent-1", data: "Working...\n" });
|
||||
emitEvent("agent:completed", { agentId: "agent-1", exitCode: 0 });
|
||||
});
|
||||
|
||||
expect(result.current.agents.get("agent-1")?.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("stores the exitCode", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
||||
emitEvent("agent:completed", { agentId: "agent-1", exitCode: 42 });
|
||||
});
|
||||
|
||||
expect(result.current.agents.get("agent-1")?.exitCode).toBe(42);
|
||||
});
|
||||
|
||||
it("sets endedAt timestamp", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
||||
emitEvent("agent:completed", { agentId: "agent-1", exitCode: 0 });
|
||||
});
|
||||
|
||||
expect(result.current.agents.get("agent-1")?.endedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("ignores completed event for unknown agent", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:completed", { agentId: "ghost-agent", exitCode: 0 });
|
||||
});
|
||||
|
||||
expect(result.current.agents.has("ghost-agent")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// SSE event: agent:error
|
||||
// ==========================================
|
||||
|
||||
describe("agent:error event", () => {
|
||||
it("sets status to error", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
||||
emitEvent("agent:error", { agentId: "agent-1", error: "Out of memory" });
|
||||
});
|
||||
|
||||
expect(result.current.agents.get("agent-1")?.status).toBe("error");
|
||||
});
|
||||
|
||||
it("stores the error message", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
||||
emitEvent("agent:error", { agentId: "agent-1", error: "Segfault" });
|
||||
});
|
||||
|
||||
expect(result.current.agents.get("agent-1")?.errorMessage).toBe("Segfault");
|
||||
});
|
||||
|
||||
it("sets endedAt on error", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
||||
emitEvent("agent:error", { agentId: "agent-1", error: "Crash" });
|
||||
});
|
||||
|
||||
expect(result.current.agents.get("agent-1")?.endedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("ignores error event for unknown agent", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:error", { agentId: "ghost-agent", error: "Crash" });
|
||||
});
|
||||
|
||||
expect(result.current.agents.has("ghost-agent")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Reconnect behavior
|
||||
// ==========================================
|
||||
|
||||
describe("auto-reconnect", () => {
|
||||
it("sets isConnected=false on error", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
});
|
||||
act(() => {
|
||||
triggerError();
|
||||
});
|
||||
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("sets connectionError on error", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
triggerError();
|
||||
});
|
||||
|
||||
expect(result.current.connectionError).not.toBeNull();
|
||||
});
|
||||
|
||||
it("creates a new EventSource after reconnect delay", () => {
|
||||
renderHook(() => useAgentStream());
|
||||
const initialCount = mockEventSourceInstances.length;
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
triggerError();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500); // initial backoff = 1000ms
|
||||
});
|
||||
|
||||
expect(mockEventSourceInstances.length).toBeGreaterThan(initialCount);
|
||||
});
|
||||
|
||||
it("closes the old EventSource before reconnecting", () => {
|
||||
renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
triggerError();
|
||||
});
|
||||
|
||||
const closedInstance = mockEventSourceInstances[0];
|
||||
expect(closedInstance?.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Cleanup on unmount
|
||||
// ==========================================
|
||||
|
||||
describe("cleanup on unmount", () => {
|
||||
it("closes EventSource when the hook is unmounted", () => {
|
||||
const { unmount } = renderHook(() => useAgentStream());
|
||||
|
||||
const es = getLatestES();
|
||||
unmount();
|
||||
|
||||
expect(es.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not attempt to reconnect after unmount", () => {
|
||||
const { unmount } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
triggerError();
|
||||
});
|
||||
|
||||
const countBeforeUnmount = mockEventSourceInstances.length;
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// No new instances created after unmount
|
||||
expect(mockEventSourceInstances.length).toBe(countBeforeUnmount);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Dismiss agent
|
||||
// ==========================================
|
||||
|
||||
describe("dismissAgent", () => {
|
||||
it("removes the agent from the map", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
||||
emitEvent("agent:completed", { agentId: "agent-1", exitCode: 0 });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.dismissAgent("agent-1");
|
||||
});
|
||||
|
||||
expect(result.current.agents.has("agent-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("is a no-op for unknown agentId", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.dismissAgent("nonexistent-agent");
|
||||
});
|
||||
|
||||
expect(result.current.agents.has("agent-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Malformed event handling
|
||||
// ==========================================
|
||||
|
||||
describe("malformed events", () => {
|
||||
it("ignores malformed JSON without throwing", () => {
|
||||
const { result } = renderHook(() => useAgentStream());
|
||||
|
||||
act(() => {
|
||||
triggerOpen();
|
||||
// Dispatch raw bad JSON
|
||||
const es = getLatestES();
|
||||
es.dispatchEvent("agent:spawned", "NOT JSON {{{");
|
||||
});
|
||||
|
||||
// Should not crash, agents map stays empty
|
||||
expect(result.current.agents.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
319
apps/web/src/hooks/useAgentStream.ts
Normal file
319
apps/web/src/hooks/useAgentStream.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useAgentStream hook
|
||||
*
|
||||
* Connects to the orchestrator SSE event stream at /api/orchestrator/events
|
||||
* and maintains a Map of agentId → AgentSession with accumulated output,
|
||||
* status, and lifecycle metadata.
|
||||
*
|
||||
* SSE event types consumed:
|
||||
* - agent:spawned — { agentId, type, jobId }
|
||||
* - agent:output — { agentId, data } (stdout/stderr lines)
|
||||
* - agent:completed — { agentId, exitCode, result }
|
||||
* - agent:error — { agentId, error }
|
||||
*
|
||||
* Features:
|
||||
* - Auto-reconnect with exponential backoff on connection loss
|
||||
* - Cleans up EventSource on unmount
|
||||
* - Accumulates output lines per agent
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
export type AgentStatus = "spawning" | "running" | "completed" | "error";
|
||||
|
||||
export interface AgentSession {
|
||||
/** Agent identifier from the orchestrator */
|
||||
agentId: string;
|
||||
/** Agent type or name (e.g., "worker", "planner") */
|
||||
agentType: string;
|
||||
/** Optional job ID this agent is associated with */
|
||||
jobId?: string;
|
||||
/** Current lifecycle status */
|
||||
status: AgentStatus;
|
||||
/** Accumulated output lines (stdout/stderr) */
|
||||
outputLines: string[];
|
||||
/** Timestamp when the agent was spawned */
|
||||
startedAt: number;
|
||||
/** Timestamp when the agent completed or errored */
|
||||
endedAt?: number;
|
||||
/** Exit code from agent:completed event */
|
||||
exitCode?: number;
|
||||
/** Error message from agent:error event */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface UseAgentStreamReturn {
|
||||
/** Map of agentId → AgentSession */
|
||||
agents: Map<string, AgentSession>;
|
||||
/** Whether the SSE stream is currently connected */
|
||||
isConnected: boolean;
|
||||
/** Connection error message, if any */
|
||||
connectionError: string | null;
|
||||
/** Dismiss (remove) an agent tab by agentId */
|
||||
dismissAgent: (agentId: string) => void;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SSE payload shapes
|
||||
// ==========================================
|
||||
|
||||
interface SpawnedPayload {
|
||||
agentId: string;
|
||||
type?: string;
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
interface OutputPayload {
|
||||
agentId: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface CompletedPayload {
|
||||
agentId: string;
|
||||
exitCode?: number;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
interface ErrorPayload {
|
||||
agentId: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Backoff config
|
||||
// ==========================================
|
||||
|
||||
const RECONNECT_BASE_MS = 1_000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
const RECONNECT_MULTIPLIER = 2;
|
||||
|
||||
// ==========================================
|
||||
// Hook
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Connects to the orchestrator SSE stream and tracks all agent sessions.
|
||||
*
|
||||
* @returns Agent sessions map, connection status, and dismiss callback
|
||||
*/
|
||||
export function useAgentStream(): UseAgentStreamReturn {
|
||||
const [agents, setAgents] = useState<Map<string, AgentSession>>(new Map());
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectDelayRef = useRef<number>(RECONNECT_BASE_MS);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// ==========================================
|
||||
// Agent state update helpers
|
||||
// ==========================================
|
||||
|
||||
const handleAgentSpawned = useCallback((payload: SpawnedPayload): void => {
|
||||
setAgents((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(payload.agentId, {
|
||||
agentId: payload.agentId,
|
||||
agentType: payload.type ?? "agent",
|
||||
...(payload.jobId !== undefined ? { jobId: payload.jobId } : {}),
|
||||
status: "spawning",
|
||||
outputLines: [],
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAgentOutput = useCallback((payload: OutputPayload): void => {
|
||||
setAgents((prev) => {
|
||||
const existing = prev.get(payload.agentId);
|
||||
if (!existing) {
|
||||
// First output for an agent we haven't seen spawned — create it
|
||||
const next = new Map(prev);
|
||||
next.set(payload.agentId, {
|
||||
agentId: payload.agentId,
|
||||
agentType: "agent",
|
||||
status: "running",
|
||||
outputLines: [payload.data],
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
const next = new Map(prev);
|
||||
next.set(payload.agentId, {
|
||||
...existing,
|
||||
status: existing.status === "spawning" ? "running" : existing.status,
|
||||
outputLines: [...existing.outputLines, payload.data],
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAgentCompleted = useCallback((payload: CompletedPayload): void => {
|
||||
setAgents((prev) => {
|
||||
const existing = prev.get(payload.agentId);
|
||||
if (!existing) return prev;
|
||||
|
||||
const next = new Map(prev);
|
||||
next.set(payload.agentId, {
|
||||
...existing,
|
||||
status: "completed",
|
||||
endedAt: Date.now(),
|
||||
...(payload.exitCode !== undefined ? { exitCode: payload.exitCode } : {}),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAgentError = useCallback((payload: ErrorPayload): void => {
|
||||
setAgents((prev) => {
|
||||
const existing = prev.get(payload.agentId);
|
||||
if (!existing) return prev;
|
||||
|
||||
const next = new Map(prev);
|
||||
next.set(payload.agentId, {
|
||||
...existing,
|
||||
status: "error",
|
||||
endedAt: Date.now(),
|
||||
...(payload.error !== undefined ? { errorMessage: payload.error } : {}),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ==========================================
|
||||
// SSE connection
|
||||
// ==========================================
|
||||
|
||||
const connect = useCallback((): void => {
|
||||
if (!isMountedRef.current) return;
|
||||
if (typeof EventSource === "undefined") return;
|
||||
|
||||
// Clean up any existing connection
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
|
||||
const es = new EventSource("/api/orchestrator/events");
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onopen = (): void => {
|
||||
if (!isMountedRef.current) return;
|
||||
setIsConnected(true);
|
||||
setConnectionError(null);
|
||||
reconnectDelayRef.current = RECONNECT_BASE_MS;
|
||||
};
|
||||
|
||||
es.onerror = (): void => {
|
||||
if (!isMountedRef.current) return;
|
||||
setIsConnected(false);
|
||||
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
// Schedule reconnect with backoff
|
||||
const delay = reconnectDelayRef.current;
|
||||
reconnectDelayRef.current = Math.min(delay * RECONNECT_MULTIPLIER, RECONNECT_MAX_MS);
|
||||
|
||||
const delaySecs = Math.round(delay / 1000).toString();
|
||||
setConnectionError(`SSE connection lost. Reconnecting in ${delaySecs}s...`);
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
connect();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
es.addEventListener("agent:spawned", (event: MessageEvent<string>) => {
|
||||
if (!isMountedRef.current) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as SpawnedPayload;
|
||||
handleAgentSpawned(payload);
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("agent:output", (event: MessageEvent<string>) => {
|
||||
if (!isMountedRef.current) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as OutputPayload;
|
||||
handleAgentOutput(payload);
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("agent:completed", (event: MessageEvent<string>) => {
|
||||
if (!isMountedRef.current) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as CompletedPayload;
|
||||
handleAgentCompleted(payload);
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("agent:error", (event: MessageEvent<string>) => {
|
||||
if (!isMountedRef.current) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as ErrorPayload;
|
||||
handleAgentError(payload);
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
});
|
||||
}, [handleAgentSpawned, handleAgentOutput, handleAgentCompleted, handleAgentError]);
|
||||
|
||||
// ==========================================
|
||||
// Mount / unmount
|
||||
// ==========================================
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
connect();
|
||||
|
||||
return (): void => {
|
||||
isMountedRef.current = false;
|
||||
|
||||
if (reconnectTimerRef.current !== null) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
// ==========================================
|
||||
// Dismiss agent
|
||||
// ==========================================
|
||||
|
||||
const dismissAgent = useCallback((agentId: string): void => {
|
||||
setAgents((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
agents,
|
||||
isConnected,
|
||||
connectionError,
|
||||
dismissAgent,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type { ChatResponse } from "@/lib/api/chat";
|
||||
// Mock the API modules - use importOriginal to preserve types/enums
|
||||
vi.mock("@/lib/api/chat", () => ({
|
||||
sendChatMessage: vi.fn(),
|
||||
streamChatMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/ideas", async (importOriginal) => {
|
||||
@@ -30,6 +31,9 @@ vi.mock("@/lib/api/ideas", async (importOriginal) => {
|
||||
const mockSendChatMessage = chatApi.sendChatMessage as MockedFunction<
|
||||
typeof chatApi.sendChatMessage
|
||||
>;
|
||||
const mockStreamChatMessage = chatApi.streamChatMessage as MockedFunction<
|
||||
typeof chatApi.streamChatMessage
|
||||
>;
|
||||
const mockCreateConversation = ideasApi.createConversation as MockedFunction<
|
||||
typeof ideasApi.createConversation
|
||||
>;
|
||||
@@ -70,9 +74,62 @@ function createMockIdea(id: string, title: string, content: string): Idea {
|
||||
} as Idea;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure streamChatMessage to immediately fail,
|
||||
* triggering the fallback to sendChatMessage.
|
||||
*/
|
||||
function makeStreamFail(): void {
|
||||
mockStreamChatMessage.mockImplementation(
|
||||
(
|
||||
_request,
|
||||
_onChunk,
|
||||
_onComplete,
|
||||
onError: (err: Error) => void,
|
||||
_signal?: AbortSignal
|
||||
): void => {
|
||||
// Call synchronously so the Promise rejects immediately
|
||||
onError(new Error("Streaming not available"));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure streamChatMessage to succeed with given tokens.
|
||||
* Uses a ref-style object to share cancellation state across the async boundary.
|
||||
*/
|
||||
function makeStreamSucceed(tokens: string[]): void {
|
||||
mockStreamChatMessage.mockImplementation(
|
||||
(
|
||||
_request,
|
||||
onChunk: (chunk: string) => void,
|
||||
onComplete: () => void,
|
||||
_onError: (err: Error) => void,
|
||||
signal?: AbortSignal
|
||||
): void => {
|
||||
const state = { cancelled: false };
|
||||
signal?.addEventListener("abort", () => {
|
||||
state.cancelled = true;
|
||||
});
|
||||
const run = async (): Promise<void> => {
|
||||
for (const token of tokens) {
|
||||
if (state.cancelled) return;
|
||||
await Promise.resolve();
|
||||
onChunk(token);
|
||||
}
|
||||
if (!state.cancelled) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
void run();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
describe("useChat", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: streaming fails so tests exercise the fallback path
|
||||
makeStreamFail();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -87,13 +144,19 @@ describe("useChat", () => {
|
||||
expect(result.current.messages[0]?.role).toBe("assistant");
|
||||
expect(result.current.messages[0]?.id).toBe("welcome");
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.conversationId).toBeNull();
|
||||
});
|
||||
|
||||
it("should expose abortStream function", () => {
|
||||
const { result } = renderHook(() => useChat());
|
||||
expect(typeof result.current.abortStream).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("should add user message and assistant response", async () => {
|
||||
describe("sendMessage (fallback path when streaming fails)", () => {
|
||||
it("should add user message and assistant response via fallback", async () => {
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Hello there!"));
|
||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
@@ -119,47 +182,13 @@ describe("useChat", () => {
|
||||
});
|
||||
|
||||
expect(mockSendChatMessage).not.toHaveBeenCalled();
|
||||
expect(mockStreamChatMessage).not.toHaveBeenCalled();
|
||||
expect(result.current.messages).toHaveLength(1); // only welcome
|
||||
});
|
||||
|
||||
it("should not send while loading", async () => {
|
||||
let resolveFirst: ((value: ChatResponse) => void) | undefined;
|
||||
const firstPromise = new Promise<ChatResponse>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
|
||||
mockSendChatMessage.mockReturnValueOnce(firstPromise);
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
// Start first message
|
||||
act(() => {
|
||||
void result.current.sendMessage("First");
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
// Try to send second while loading
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Second");
|
||||
});
|
||||
|
||||
// Should only have one call
|
||||
expect(mockSendChatMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Cleanup - resolve the pending promise
|
||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||
await act(async () => {
|
||||
if (resolveFirst) {
|
||||
resolveFirst(createMockChatResponse("Response"));
|
||||
}
|
||||
// Allow promise to settle
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle API errors gracefully", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockRejectedValueOnce(new Error("API Error"));
|
||||
|
||||
const onError = vi.fn();
|
||||
@@ -171,46 +200,178 @@ describe("useChat", () => {
|
||||
|
||||
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
||||
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
||||
// Should have welcome + user + error message
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("streaming path", () => {
|
||||
it("should stream tokens into assistant message", async () => {
|
||||
const tokens = ["Hello", " world", "!"];
|
||||
makeStreamSucceed(tokens);
|
||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Hi");
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[2]?.role).toBe("assistant");
|
||||
expect(result.current.messages[2]?.content).toBe("Hello world!");
|
||||
});
|
||||
|
||||
it("should set isStreaming true during streaming then false when done", async () => {
|
||||
let capturedOnChunk: ((chunk: string) => void) | undefined;
|
||||
let capturedOnComplete: (() => void) | undefined;
|
||||
|
||||
mockStreamChatMessage.mockImplementation(
|
||||
(
|
||||
_request,
|
||||
onChunk: (chunk: string) => void,
|
||||
onComplete: () => void,
|
||||
_onError: (err: Error) => void
|
||||
): void => {
|
||||
capturedOnChunk = onChunk;
|
||||
capturedOnComplete = onComplete;
|
||||
}
|
||||
);
|
||||
|
||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
let sendDone = false;
|
||||
act(() => {
|
||||
void result.current.sendMessage("Hello").then(() => {
|
||||
sendDone = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Send first token (triggers streaming state)
|
||||
await act(async () => {
|
||||
capturedOnChunk?.("Hello");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
// Complete the stream
|
||||
await act(async () => {
|
||||
capturedOnComplete?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(sendDone).toBe(true);
|
||||
});
|
||||
|
||||
it("should keep partial content on abort", async () => {
|
||||
let capturedOnChunk: ((chunk: string) => void) | undefined;
|
||||
|
||||
mockStreamChatMessage.mockImplementation(
|
||||
(
|
||||
_request,
|
||||
onChunk: (chunk: string) => void,
|
||||
_onComplete: () => void,
|
||||
_onError: (err: Error) => void,
|
||||
signal?: AbortSignal
|
||||
): void => {
|
||||
capturedOnChunk = onChunk;
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", () => {
|
||||
// Stream aborted
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
act(() => {
|
||||
void result.current.sendMessage("Hello");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedOnChunk?.("Partial");
|
||||
capturedOnChunk?.(" content");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.abortStream();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
const assistantMsg = result.current.messages.find(
|
||||
(m) => m.role === "assistant" && m.id !== "welcome"
|
||||
);
|
||||
expect(assistantMsg?.content).toBe("Partial content");
|
||||
});
|
||||
|
||||
it("should not send while streaming", async () => {
|
||||
let capturedOnChunk: ((chunk: string) => void) | undefined;
|
||||
|
||||
mockStreamChatMessage.mockImplementation(
|
||||
(
|
||||
_request,
|
||||
onChunk: (chunk: string) => void,
|
||||
_onComplete: () => void,
|
||||
_onError: (err: Error) => void
|
||||
): void => {
|
||||
capturedOnChunk = onChunk;
|
||||
}
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
act(() => {
|
||||
void result.current.sendMessage("First");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedOnChunk?.("token");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Second");
|
||||
});
|
||||
|
||||
// Only one stream call
|
||||
expect(mockStreamChatMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rapid sends - stale closure prevention", () => {
|
||||
it("should not lose messages on rapid sequential sends", async () => {
|
||||
// This test verifies that functional state updates prevent message loss
|
||||
// when multiple messages are sent in quick succession
|
||||
|
||||
let callCount = 0;
|
||||
mockSendChatMessage.mockImplementation(async (): Promise<ChatResponse> => {
|
||||
callCount++;
|
||||
// Small delay to simulate network
|
||||
await Promise.resolve();
|
||||
return createMockChatResponse(`Response ${String(callCount)}`);
|
||||
});
|
||||
// Use streaming success path for deterministic behavior
|
||||
makeStreamSucceed(["Response 1"]);
|
||||
|
||||
mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
// Send first message
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Message 1");
|
||||
});
|
||||
|
||||
// Verify first message cycle complete
|
||||
expect(result.current.messages).toHaveLength(3); // welcome + user1 + assistant1
|
||||
|
||||
// Send second message
|
||||
makeStreamSucceed(["Response 2"]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Message 2");
|
||||
});
|
||||
|
||||
// Verify all messages are present (no data loss)
|
||||
expect(result.current.messages).toHaveLength(5); // welcome + user1 + assistant1 + user2 + assistant2
|
||||
|
||||
// Verify message order and content
|
||||
const userMessages = result.current.messages.filter((m) => m.role === "user");
|
||||
expect(userMessages).toHaveLength(2);
|
||||
expect(userMessages[0]?.content).toBe("Message 1");
|
||||
@@ -218,69 +379,56 @@ describe("useChat", () => {
|
||||
});
|
||||
|
||||
it("should use functional updates for all message state changes", async () => {
|
||||
// This test verifies that the implementation uses functional updates
|
||||
// by checking that messages accumulate correctly
|
||||
|
||||
mockSendChatMessage.mockResolvedValue(createMockChatResponse("Response"));
|
||||
mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
// Track message count after each operation
|
||||
const messageCounts: number[] = [];
|
||||
|
||||
makeStreamSucceed(["R1"]);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Test 1");
|
||||
});
|
||||
messageCounts.push(result.current.messages.length);
|
||||
|
||||
makeStreamSucceed(["R2"]);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Test 2");
|
||||
});
|
||||
messageCounts.push(result.current.messages.length);
|
||||
|
||||
makeStreamSucceed(["R3"]);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Test 3");
|
||||
});
|
||||
messageCounts.push(result.current.messages.length);
|
||||
|
||||
// Should accumulate: 3, 5, 7 (welcome + pairs of user/assistant)
|
||||
expect(messageCounts).toEqual([3, 5, 7]);
|
||||
|
||||
// Verify final state has all messages
|
||||
expect(result.current.messages).toHaveLength(7);
|
||||
const userMessages = result.current.messages.filter((m) => m.role === "user");
|
||||
expect(userMessages).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should maintain correct message order with ref-based state tracking", async () => {
|
||||
// This test verifies that messagesRef is properly synchronized
|
||||
|
||||
const responses = ["First response", "Second response", "Third response"];
|
||||
let responseIndex = 0;
|
||||
|
||||
mockSendChatMessage.mockImplementation((): Promise<ChatResponse> => {
|
||||
const response = responses[responseIndex++];
|
||||
return Promise.resolve(createMockChatResponse(response ?? ""));
|
||||
});
|
||||
|
||||
mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
makeStreamSucceed(["First response"]);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Query 1");
|
||||
});
|
||||
|
||||
makeStreamSucceed(["Second response"]);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Query 2");
|
||||
});
|
||||
|
||||
makeStreamSucceed(["Third response"]);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Query 3");
|
||||
});
|
||||
|
||||
// Verify messages are in correct order
|
||||
const messages = result.current.messages;
|
||||
expect(messages[0]?.id).toBe("welcome");
|
||||
expect(messages[1]?.content).toBe("Query 1");
|
||||
@@ -337,14 +485,12 @@ describe("useChat", () => {
|
||||
await result.current.loadConversation("conv-bad");
|
||||
});
|
||||
|
||||
// Should fall back to welcome message
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]?.id).toBe("welcome");
|
||||
});
|
||||
|
||||
it("should fall back to welcome message when stored data has wrong shape", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
// Valid JSON but wrong shape (object instead of array, missing required fields)
|
||||
mockGetIdea.mockResolvedValueOnce(
|
||||
createMockIdea("conv-bad", "Wrong Shape", JSON.stringify({ not: "an array" }))
|
||||
);
|
||||
@@ -408,7 +554,6 @@ describe("useChat", () => {
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
// Should resolve without throwing - errors are handled internally
|
||||
await act(async () => {
|
||||
await expect(result.current.loadConversation("conv-err")).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -419,19 +564,17 @@ describe("useChat", () => {
|
||||
|
||||
describe("startNewConversation", () => {
|
||||
it("should reset to initial state", async () => {
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
|
||||
makeStreamSucceed(["Response"]);
|
||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
// Send a message to have some state
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Hello");
|
||||
});
|
||||
|
||||
expect(result.current.messages.length).toBeGreaterThan(1);
|
||||
|
||||
// Start new conversation
|
||||
act(() => {
|
||||
result.current.startNewConversation();
|
||||
});
|
||||
@@ -446,6 +589,7 @@ describe("useChat", () => {
|
||||
describe("clearError", () => {
|
||||
it("should clear error state", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Test error"));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
@@ -467,6 +611,7 @@ describe("useChat", () => {
|
||||
describe("error context logging", () => {
|
||||
it("should log comprehensive error context when sendMessage fails", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockRejectedValueOnce(new Error("LLM timeout"));
|
||||
|
||||
const { result } = renderHook(() => useChat({ model: "llama3.2" }));
|
||||
@@ -489,6 +634,7 @@ describe("useChat", () => {
|
||||
|
||||
it("should truncate long message previews to 50 characters", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Failed"));
|
||||
|
||||
const longMessage = "A".repeat(100);
|
||||
@@ -509,9 +655,10 @@ describe("useChat", () => {
|
||||
|
||||
it("should include message count in error context", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
// First successful message
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK"));
|
||||
// First successful message via streaming
|
||||
makeStreamSucceed(["OK"]);
|
||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
@@ -520,14 +667,14 @@ describe("useChat", () => {
|
||||
await result.current.sendMessage("First");
|
||||
});
|
||||
|
||||
// Second message fails
|
||||
// Second message: streaming fails, fallback fails
|
||||
makeStreamFail();
|
||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Fail"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Second");
|
||||
});
|
||||
|
||||
// messageCount should reflect messages including the new user message
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to send chat message",
|
||||
expect.objectContaining({
|
||||
@@ -540,6 +687,7 @@ describe("useChat", () => {
|
||||
describe("LLM vs persistence error separation", () => {
|
||||
it("should show LLM error and add error message to chat when API fails", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available"));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
@@ -549,13 +697,29 @@ describe("useChat", () => {
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
||||
// Should have welcome + user + error message
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
||||
});
|
||||
|
||||
it("should keep assistant message visible when save fails", async () => {
|
||||
it("should keep assistant message visible when save fails (streaming path)", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
makeStreamSucceed(["Great answer!"]);
|
||||
mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost"));
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Hello");
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(3); // welcome + user + assistant
|
||||
expect(result.current.messages[2]?.content).toBe("Great answer!");
|
||||
expect(result.current.error).toContain("Message sent but failed to save");
|
||||
});
|
||||
|
||||
it("should keep assistant message visible when save fails (fallback path)", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Great answer!"));
|
||||
mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost"));
|
||||
|
||||
@@ -565,16 +729,14 @@ describe("useChat", () => {
|
||||
await result.current.sendMessage("Hello");
|
||||
});
|
||||
|
||||
// Assistant message should still be visible
|
||||
expect(result.current.messages).toHaveLength(3); // welcome + user + assistant
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[2]?.content).toBe("Great answer!");
|
||||
|
||||
// Error should indicate persistence failure
|
||||
expect(result.current.error).toContain("Message sent but failed to save");
|
||||
});
|
||||
|
||||
it("should log with PERSISTENCE_ERROR type when save fails", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
|
||||
mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
|
||||
|
||||
@@ -591,7 +753,6 @@ describe("useChat", () => {
|
||||
})
|
||||
);
|
||||
|
||||
// Should NOT have logged as LLM_ERROR
|
||||
const llmErrorCalls = consoleSpy.mock.calls.filter((call) => {
|
||||
const ctx: unknown = call[1];
|
||||
return (
|
||||
@@ -606,8 +767,9 @@ describe("useChat", () => {
|
||||
|
||||
it("should use different user-facing messages for LLM vs save errors", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
// Test LLM error message
|
||||
// LLM error path (streaming fails + fallback fails)
|
||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Timeout"));
|
||||
const { result: result1 } = renderHook(() => useChat());
|
||||
|
||||
@@ -617,8 +779,8 @@ describe("useChat", () => {
|
||||
|
||||
const llmError = result1.current.error;
|
||||
|
||||
// Test save error message
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK"));
|
||||
// Save error path (streaming succeeds, save fails)
|
||||
makeStreamSucceed(["OK"]);
|
||||
mockCreateConversation.mockRejectedValueOnce(new Error("DB down"));
|
||||
const { result: result2 } = renderHook(() => useChat());
|
||||
|
||||
@@ -628,7 +790,6 @@ describe("useChat", () => {
|
||||
|
||||
const saveError = result2.current.error;
|
||||
|
||||
// They should be different
|
||||
expect(llmError).toBe("Unable to send message. Please try again.");
|
||||
expect(saveError).toContain("Message sent but failed to save");
|
||||
expect(llmError).not.toEqual(saveError);
|
||||
@@ -636,6 +797,7 @@ describe("useChat", () => {
|
||||
|
||||
it("should handle non-Error throws from LLM API", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockRejectedValueOnce("string error");
|
||||
|
||||
const onError = vi.fn();
|
||||
@@ -652,7 +814,8 @@ describe("useChat", () => {
|
||||
|
||||
it("should handle non-Error throws from persistence layer", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK"));
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
makeStreamSucceed(["OK"]);
|
||||
mockCreateConversation.mockRejectedValueOnce("DB string error");
|
||||
|
||||
const onError = vi.fn();
|
||||
@@ -662,7 +825,6 @@ describe("useChat", () => {
|
||||
await result.current.sendMessage("Hello");
|
||||
});
|
||||
|
||||
// Assistant message should still be visible
|
||||
expect(result.current.messages[2]?.content).toBe("OK");
|
||||
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
||||
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
||||
@@ -670,8 +832,9 @@ describe("useChat", () => {
|
||||
|
||||
it("should handle updateConversation failure for existing conversations", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
// First message succeeds and creates conversation
|
||||
// First message via fallback
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("First response"));
|
||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
||||
|
||||
@@ -683,7 +846,8 @@ describe("useChat", () => {
|
||||
|
||||
expect(result.current.conversationId).toBe("conv-1");
|
||||
|
||||
// Second message succeeds but updateConversation fails
|
||||
// Second message via fallback, updateConversation fails
|
||||
makeStreamFail();
|
||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Second response"));
|
||||
mockUpdateConversation.mockRejectedValueOnce(new Error("Connection reset"));
|
||||
|
||||
@@ -691,8 +855,10 @@ describe("useChat", () => {
|
||||
await result.current.sendMessage("Second");
|
||||
});
|
||||
|
||||
// Assistant message should still be visible
|
||||
expect(result.current.messages[4]?.content).toBe("Second response");
|
||||
const assistantMessages = result.current.messages.filter(
|
||||
(m) => m.role === "assistant" && m.id !== "welcome"
|
||||
);
|
||||
expect(assistantMessages[assistantMessages.length - 1]?.content).toBe("Second response");
|
||||
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { sendChatMessage, type ChatMessage as ApiChatMessage } from "@/lib/api/chat";
|
||||
import {
|
||||
sendChatMessage,
|
||||
streamChatMessage,
|
||||
type ChatMessage as ApiChatMessage,
|
||||
} from "@/lib/api/chat";
|
||||
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
|
||||
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
|
||||
|
||||
@@ -33,10 +37,12 @@ export interface UseChatOptions {
|
||||
export interface UseChatReturn {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
isStreaming: boolean;
|
||||
error: string | null;
|
||||
conversationId: string | null;
|
||||
conversationTitle: string | null;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
abortStream: () => void;
|
||||
loadConversation: (ideaId: string) => Promise<void>;
|
||||
startNewConversation: (projectId?: string | null) => void;
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||
@@ -66,6 +72,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([WELCOME_MESSAGE]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [conversationTitle, setConversationTitle] = useState<string | null>(null);
|
||||
@@ -78,6 +85,16 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
const messagesRef = useRef<Message[]>(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
// AbortController ref for the active stream
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Track conversation state in refs to avoid stale closures in streaming callbacks
|
||||
const conversationIdRef = useRef<string | null>(conversationId);
|
||||
conversationIdRef.current = conversationId;
|
||||
|
||||
const conversationTitleRef = useRef<string | null>(conversationTitle);
|
||||
conversationTitleRef.current = conversationTitle;
|
||||
|
||||
/**
|
||||
* Convert our Message format to API ChatMessage format
|
||||
*/
|
||||
@@ -119,44 +136,57 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Save conversation to backend
|
||||
* Save conversation to backend.
|
||||
* Uses refs for conversation state to avoid stale closures in streaming callbacks.
|
||||
*/
|
||||
const saveConversation = useCallback(
|
||||
async (msgs: Message[], title: string): Promise<string> => {
|
||||
const content = serializeMessages(msgs);
|
||||
const currentConvId = conversationIdRef.current;
|
||||
|
||||
if (conversationId) {
|
||||
// Update existing conversation
|
||||
await updateConversation(conversationId, content, title);
|
||||
return conversationId;
|
||||
if (currentConvId) {
|
||||
await updateConversation(currentConvId, content, title);
|
||||
return currentConvId;
|
||||
} else {
|
||||
// Create new conversation
|
||||
const idea = await createConversation(title, content, projectIdRef.current ?? undefined);
|
||||
setConversationId(idea.id);
|
||||
setConversationTitle(title);
|
||||
conversationIdRef.current = idea.id;
|
||||
conversationTitleRef.current = title;
|
||||
return idea.id;
|
||||
}
|
||||
},
|
||||
[conversationId, serializeMessages]
|
||||
[serializeMessages]
|
||||
);
|
||||
|
||||
/**
|
||||
* Send a message to the LLM and save the conversation
|
||||
* Abort an active stream
|
||||
*/
|
||||
const abortStream = useCallback((): void => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
setIsStreaming(false);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Send a message to the LLM using streaming, with fallback to non-streaming
|
||||
*/
|
||||
const sendMessage = useCallback(
|
||||
async (content: string): Promise<void> => {
|
||||
if (!content.trim() || isLoading) {
|
||||
if (!content.trim() || isLoading || isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
id: `user-${Date.now().toString()}`,
|
||||
id: `user-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Add user message immediately using functional update
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev, userMessage];
|
||||
messagesRef.current = updated;
|
||||
@@ -165,95 +195,186 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const assistantMessageId = `assistant-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const placeholderMessage: Message = {
|
||||
id: assistantMessageId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
model,
|
||||
};
|
||||
|
||||
const currentMessages = messagesRef.current;
|
||||
const apiMessages = convertToApiMessages(currentMessages);
|
||||
|
||||
const request = {
|
||||
model,
|
||||
messages: apiMessages,
|
||||
...(temperature !== undefined && { temperature }),
|
||||
...(maxTokens !== undefined && { maxTokens }),
|
||||
...(systemPrompt !== undefined && { systemPrompt }),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
let streamingSucceeded = false;
|
||||
|
||||
try {
|
||||
// Prepare API request - use ref to get current messages (prevents stale closure)
|
||||
const currentMessages = messagesRef.current;
|
||||
const apiMessages = convertToApiMessages(currentMessages);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let hasReceivedData = false;
|
||||
|
||||
const request = {
|
||||
model,
|
||||
messages: apiMessages,
|
||||
...(temperature !== undefined && { temperature }),
|
||||
...(maxTokens !== undefined && { maxTokens }),
|
||||
...(systemPrompt !== undefined && { systemPrompt }),
|
||||
};
|
||||
streamChatMessage(
|
||||
request,
|
||||
(chunk: string) => {
|
||||
if (!hasReceivedData) {
|
||||
hasReceivedData = true;
|
||||
setIsLoading(false);
|
||||
setIsStreaming(true);
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev, { ...placeholderMessage }];
|
||||
messagesRef.current = updated;
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
// Call LLM API
|
||||
const response = await sendChatMessage(request);
|
||||
|
||||
// Create assistant message
|
||||
const assistantMessage: Message = {
|
||||
id: `assistant-${Date.now().toString()}`,
|
||||
role: "assistant",
|
||||
content: response.message.content,
|
||||
createdAt: new Date().toISOString(),
|
||||
model: response.model,
|
||||
promptTokens: response.promptEvalCount ?? 0,
|
||||
completionTokens: response.evalCount ?? 0,
|
||||
totalTokens: (response.promptEvalCount ?? 0) + (response.evalCount ?? 0),
|
||||
};
|
||||
|
||||
// Add assistant message using functional update
|
||||
let finalMessages: Message[] = [];
|
||||
setMessages((prev) => {
|
||||
finalMessages = [...prev, assistantMessage];
|
||||
messagesRef.current = finalMessages;
|
||||
return finalMessages;
|
||||
setMessages((prev) => {
|
||||
const updated = prev.map((msg) =>
|
||||
msg.id === assistantMessageId ? { ...msg, content: msg.content + chunk } : msg
|
||||
);
|
||||
messagesRef.current = updated;
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
() => {
|
||||
streamingSucceeded = true;
|
||||
setIsStreaming(false);
|
||||
abortControllerRef.current = null;
|
||||
resolve();
|
||||
},
|
||||
(err: Error) => {
|
||||
reject(err);
|
||||
},
|
||||
controller.signal
|
||||
);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (controller.signal.aborted) {
|
||||
setIsStreaming(false);
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
|
||||
// Generate title from first user message if this is a new conversation
|
||||
const isFirstMessage =
|
||||
!conversationId && finalMessages.filter((m) => m.role === "user").length === 1;
|
||||
const title = isFirstMessage
|
||||
? generateTitle(content)
|
||||
: (conversationTitle ?? "Chat Conversation");
|
||||
|
||||
// Save conversation (separate error handling from LLM errors)
|
||||
try {
|
||||
await saveConversation(finalMessages, title);
|
||||
} catch (saveErr) {
|
||||
const saveErrorMsg =
|
||||
saveErr instanceof Error ? saveErr.message : "Unknown persistence error";
|
||||
setError("Message sent but failed to save. Please try again.");
|
||||
onError?.(saveErr instanceof Error ? saveErr : new Error(saveErrorMsg));
|
||||
console.error("Failed to save conversation", {
|
||||
error: saveErr,
|
||||
errorType: "PERSISTENCE_ERROR",
|
||||
conversationId,
|
||||
detail: saveErrorMsg,
|
||||
// Remove placeholder if no content was received
|
||||
setMessages((prev) => {
|
||||
const assistantMsg = prev.find((m) => m.id === assistantMessageId);
|
||||
if (assistantMsg?.content === "") {
|
||||
const updated = prev.filter((m) => m.id !== assistantMessageId);
|
||||
messagesRef.current = updated;
|
||||
return updated;
|
||||
}
|
||||
messagesRef.current = prev;
|
||||
return prev;
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to send message";
|
||||
setError("Unable to send message. Please try again.");
|
||||
onError?.(err instanceof Error ? err : new Error(errorMsg));
|
||||
console.error("Failed to send chat message", {
|
||||
error: err,
|
||||
errorType: "LLM_ERROR",
|
||||
conversationId,
|
||||
messageLength: content.length,
|
||||
messagePreview: content.substring(0, 50),
|
||||
model,
|
||||
messageCount: messagesRef.current.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
// Streaming failed — fall back to non-streaming
|
||||
console.warn("Streaming failed, falling back to non-streaming", {
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
});
|
||||
|
||||
// Add error message to chat
|
||||
const errorMessage: Message = {
|
||||
id: `error-${String(Date.now())}`,
|
||||
role: "assistant",
|
||||
content: "Something went wrong. Please try again.",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setMessages((prev) => {
|
||||
const withoutPlaceholder = prev.filter((m) => m.id !== assistantMessageId);
|
||||
messagesRef.current = withoutPlaceholder;
|
||||
return withoutPlaceholder;
|
||||
});
|
||||
setIsStreaming(false);
|
||||
|
||||
try {
|
||||
const response = await sendChatMessage(request);
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `assistant-${Date.now().toString()}`,
|
||||
role: "assistant",
|
||||
content: response.message.content,
|
||||
createdAt: new Date().toISOString(),
|
||||
model: response.model,
|
||||
promptTokens: response.promptEvalCount ?? 0,
|
||||
completionTokens: response.evalCount ?? 0,
|
||||
totalTokens: (response.promptEvalCount ?? 0) + (response.evalCount ?? 0),
|
||||
};
|
||||
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev, assistantMessage];
|
||||
messagesRef.current = updated;
|
||||
return updated;
|
||||
});
|
||||
|
||||
streamingSucceeded = true;
|
||||
} catch (fallbackErr: unknown) {
|
||||
const errorMsg =
|
||||
fallbackErr instanceof Error ? fallbackErr.message : "Failed to send message";
|
||||
setError("Unable to send message. Please try again.");
|
||||
onError?.(fallbackErr instanceof Error ? fallbackErr : new Error(errorMsg));
|
||||
console.error("Failed to send chat message", {
|
||||
error: fallbackErr,
|
||||
errorType: "LLM_ERROR",
|
||||
conversationId: conversationIdRef.current,
|
||||
messageLength: content.length,
|
||||
messagePreview: content.substring(0, 50),
|
||||
model,
|
||||
messageCount: messagesRef.current.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: `error-${String(Date.now())}`,
|
||||
role: "assistant",
|
||||
content: "Something went wrong. Please try again.",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev, errorMessage];
|
||||
messagesRef.current = updated;
|
||||
return updated;
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (!streamingSucceeded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalMessages = messagesRef.current;
|
||||
|
||||
const isFirstMessage =
|
||||
!conversationIdRef.current && finalMessages.filter((m) => m.role === "user").length === 1;
|
||||
const title = isFirstMessage
|
||||
? generateTitle(content)
|
||||
: (conversationTitleRef.current ?? "Chat Conversation");
|
||||
|
||||
try {
|
||||
await saveConversation(finalMessages, title);
|
||||
} catch (saveErr) {
|
||||
const saveErrorMsg =
|
||||
saveErr instanceof Error ? saveErr.message : "Unknown persistence error";
|
||||
setError("Message sent but failed to save. Please try again.");
|
||||
onError?.(saveErr instanceof Error ? saveErr : new Error(saveErrorMsg));
|
||||
console.error("Failed to save conversation", {
|
||||
error: saveErr,
|
||||
errorType: "PERSISTENCE_ERROR",
|
||||
conversationId: conversationIdRef.current,
|
||||
detail: saveErrorMsg,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
isLoading,
|
||||
conversationId,
|
||||
conversationTitle,
|
||||
isStreaming,
|
||||
model,
|
||||
temperature,
|
||||
maxTokens,
|
||||
@@ -280,6 +401,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
setMessages(msgs);
|
||||
setConversationId(idea.id);
|
||||
setConversationTitle(idea.title ?? null);
|
||||
conversationIdRef.current = idea.id;
|
||||
conversationTitleRef.current = idea.title ?? null;
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to load conversation";
|
||||
setError("Unable to load conversation. Please try again.");
|
||||
@@ -305,6 +428,8 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
setConversationId(null);
|
||||
setConversationTitle(null);
|
||||
setError(null);
|
||||
conversationIdRef.current = null;
|
||||
conversationTitleRef.current = null;
|
||||
projectIdRef.current = newProjectId ?? null;
|
||||
}, []);
|
||||
|
||||
@@ -318,10 +443,12 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
isStreaming,
|
||||
error,
|
||||
conversationId,
|
||||
conversationTitle,
|
||||
sendMessage,
|
||||
abortStream,
|
||||
loadConversation,
|
||||
startNewConversation,
|
||||
setMessages,
|
||||
|
||||
293
apps/web/src/hooks/useOrchestratorCommands.test.ts
Normal file
293
apps/web/src/hooks/useOrchestratorCommands.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Tests for useOrchestratorCommands hook
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useOrchestratorCommands } from "./useOrchestratorCommands";
|
||||
import type { Message } from "./useChat";
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
function makeOkResponse(data: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(data),
|
||||
text: () => Promise.resolve(JSON.stringify(data)),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
/** Run executeCommand and return the result synchronously after act() */
|
||||
async function runCommand(
|
||||
executeCommand: (content: string) => Promise<Message | null>,
|
||||
content: string
|
||||
): Promise<Message | null> {
|
||||
let msg: Message | null = null;
|
||||
await act(async () => {
|
||||
msg = await executeCommand(content);
|
||||
});
|
||||
return msg;
|
||||
}
|
||||
|
||||
describe("useOrchestratorCommands", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("isCommand", () => {
|
||||
it("returns true for messages starting with /", () => {
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
expect(result.current.isCommand("/status")).toBe(true);
|
||||
expect(result.current.isCommand("/agents")).toBe(true);
|
||||
expect(result.current.isCommand("/help")).toBe(true);
|
||||
expect(result.current.isCommand(" /status")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for regular messages", () => {
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
expect(result.current.isCommand("hello")).toBe(false);
|
||||
expect(result.current.isCommand("tell me about /status")).toBe(false);
|
||||
expect(result.current.isCommand("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeCommand", () => {
|
||||
describe("/help", () => {
|
||||
it("returns help message without network calls", async () => {
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/help");
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg?.role).toBe("assistant");
|
||||
expect(msg?.content).toContain("/status");
|
||||
expect(msg?.content).toContain("/agents");
|
||||
expect(msg?.content).toContain("/jobs");
|
||||
expect(msg?.content).toContain("/pause");
|
||||
expect(msg?.content).toContain("/resume");
|
||||
});
|
||||
|
||||
it("returns message with id and createdAt", async () => {
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/help");
|
||||
|
||||
expect(msg?.id).toBeDefined();
|
||||
expect(msg?.createdAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/status", () => {
|
||||
it("calls /api/orchestrator/health and returns formatted status", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
makeOkResponse({ status: "ready", version: "1.2.3", uptime: 3661 })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/status");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/health", { method: "GET" });
|
||||
expect(msg?.role).toBe("assistant");
|
||||
expect(msg?.content).toContain("Ready");
|
||||
expect(msg?.content).toContain("1.2.3");
|
||||
expect(msg?.content).toContain("1h");
|
||||
});
|
||||
|
||||
it("shows Not Ready when status is not ready", async () => {
|
||||
mockFetch.mockResolvedValueOnce(makeOkResponse({ status: "not-ready" }));
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/status");
|
||||
|
||||
expect(msg?.content).toContain("Not Ready");
|
||||
});
|
||||
|
||||
it("handles network error gracefully", async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/status");
|
||||
|
||||
expect(msg?.role).toBe("assistant");
|
||||
expect(msg?.content).toContain("Error");
|
||||
expect(msg?.content).toContain("Connection refused");
|
||||
});
|
||||
|
||||
it("shows error from API response", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
makeOkResponse({ error: "ORCHESTRATOR_API_KEY is not configured" })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/status");
|
||||
|
||||
expect(msg?.content).toContain("Not reachable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/agents", () => {
|
||||
it("calls /api/orchestrator/agents and returns agent table", async () => {
|
||||
const agents = [
|
||||
{ id: "agent-1", status: "active", type: "codex", startedAt: "2026-02-25T10:00:00Z" },
|
||||
{
|
||||
id: "agent-2",
|
||||
agentStatus: "TERMINATED",
|
||||
channel: "claude",
|
||||
startedAt: "2026-02-25T09:00:00Z",
|
||||
},
|
||||
];
|
||||
mockFetch.mockResolvedValueOnce(makeOkResponse(agents));
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/agents");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/agents", { method: "GET" });
|
||||
expect(msg?.content).toContain("agent-1");
|
||||
expect(msg?.content).toContain("agent-2");
|
||||
expect(msg?.content).toContain("TERMINATED");
|
||||
});
|
||||
|
||||
it("handles empty agent list", async () => {
|
||||
mockFetch.mockResolvedValueOnce(makeOkResponse([]));
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/agents");
|
||||
|
||||
expect(msg?.content).toContain("No agents currently running");
|
||||
});
|
||||
|
||||
it("handles agents in nested object", async () => {
|
||||
const data = {
|
||||
agents: [{ id: "agent-nested", status: "active" }],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(makeOkResponse(data));
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/agents");
|
||||
|
||||
expect(msg?.content).toContain("agent-nested");
|
||||
});
|
||||
|
||||
it("handles network error gracefully", async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error("Timeout"));
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/agents");
|
||||
|
||||
expect(msg?.content).toContain("Error");
|
||||
expect(msg?.content).toContain("Timeout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/jobs", () => {
|
||||
it("calls /api/orchestrator/queue/stats", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
makeOkResponse({ pending: 3, active: 1, completed: 42, failed: 0 })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/jobs");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/queue/stats", { method: "GET" });
|
||||
expect(msg?.content).toContain("3");
|
||||
expect(msg?.content).toContain("42");
|
||||
expect(msg?.content).toContain("Pending");
|
||||
expect(msg?.content).toContain("Completed");
|
||||
});
|
||||
|
||||
it("/queue is an alias for /jobs", async () => {
|
||||
mockFetch.mockResolvedValueOnce(makeOkResponse({ pending: 0, active: 0 }));
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/queue");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/queue/stats", { method: "GET" });
|
||||
expect(msg?.role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("shows paused indicator when queue is paused", async () => {
|
||||
mockFetch.mockResolvedValueOnce(makeOkResponse({ pending: 0, active: 0, paused: true }));
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/jobs");
|
||||
|
||||
expect(msg?.content).toContain("paused");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/pause", () => {
|
||||
it("calls POST /api/orchestrator/queue/pause", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
makeOkResponse({ success: true, message: "Queue paused." })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/pause");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/queue/pause", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(msg?.content).toContain("paused");
|
||||
});
|
||||
|
||||
it("handles API error response", async () => {
|
||||
mockFetch.mockResolvedValueOnce(makeOkResponse({ error: "Already paused." }));
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/pause");
|
||||
|
||||
expect(msg?.content).toContain("failed");
|
||||
expect(msg?.content).toContain("Already paused");
|
||||
});
|
||||
|
||||
it("handles network error", async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network failure"));
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/pause");
|
||||
|
||||
expect(msg?.content).toContain("Error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/resume", () => {
|
||||
it("calls POST /api/orchestrator/queue/resume", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
makeOkResponse({ success: true, message: "Queue resumed." })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/resume");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/queue/resume", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(msg?.content).toContain("resumed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown command", () => {
|
||||
it("returns help hint for unknown commands", async () => {
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "/unknown-command");
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(msg?.content).toContain("Unknown command");
|
||||
expect(msg?.content).toContain("/unknown-command");
|
||||
expect(msg?.content).toContain("/help");
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-command input", () => {
|
||||
it("returns null for regular messages", async () => {
|
||||
const { result } = renderHook(() => useOrchestratorCommands());
|
||||
const msg = await runCommand(result.current.executeCommand, "hello world");
|
||||
|
||||
expect(msg).toBeNull();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
356
apps/web/src/hooks/useOrchestratorCommands.ts
Normal file
356
apps/web/src/hooks/useOrchestratorCommands.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* useOrchestratorCommands hook
|
||||
*
|
||||
* Parses chat messages for `/command` prefixes and routes them to the
|
||||
* orchestrator proxy API routes instead of the LLM.
|
||||
*
|
||||
* Supported commands:
|
||||
* /status — GET /api/orchestrator/health
|
||||
* /agents — GET /api/orchestrator/agents
|
||||
* /jobs — GET /api/orchestrator/queue/stats
|
||||
* /queue — alias for /jobs
|
||||
* /pause — POST /api/orchestrator/queue/pause
|
||||
* /resume — POST /api/orchestrator/queue/resume
|
||||
* /help — Display available commands locally (no API call)
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { Message } from "@/hooks/useChat";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OrchestratorCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
export const ORCHESTRATOR_COMMANDS: OrchestratorCommand[] = [
|
||||
{ name: "/status", description: "Show orchestrator health and status" },
|
||||
{ name: "/agents", description: "List all running agents" },
|
||||
{ name: "/jobs", description: "Show queue statistics", aliases: ["/queue"] },
|
||||
{ name: "/pause", description: "Pause the job queue" },
|
||||
{ name: "/resume", description: "Resume the job queue" },
|
||||
{ name: "/help", description: "Show available commands" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response shapes (loosely typed — orchestrator may vary)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HealthResponse {
|
||||
status?: string;
|
||||
version?: string;
|
||||
uptime?: number;
|
||||
ready?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Agent {
|
||||
id?: string;
|
||||
sessionKey?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
agentStatus?: string;
|
||||
startedAt?: string;
|
||||
label?: string;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
interface AgentsResponse {
|
||||
agents?: Agent[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface QueueStats {
|
||||
pending?: number;
|
||||
active?: number;
|
||||
completed?: number;
|
||||
failed?: number;
|
||||
waiting?: number;
|
||||
delayed?: number;
|
||||
paused?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ActionResponse {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeId(): string {
|
||||
return `orch-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function makeMessage(content: string): Message {
|
||||
return {
|
||||
id: makeId(),
|
||||
role: "assistant",
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(command: string, detail: string): Message {
|
||||
return makeMessage(
|
||||
`**Error running \`${command}\`**\n\n${detail}\n\n_Check that the orchestrator is running and the API key is configured._`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatStatus(data: HealthResponse): string {
|
||||
if (data.error) {
|
||||
return `**Orchestrator Status**\n\nStatus: Not reachable\n\nError: ${data.error}`;
|
||||
}
|
||||
|
||||
const statusLabel = data.status ?? (data.ready === true ? "ready" : "unknown");
|
||||
const isReady =
|
||||
statusLabel === "ready" ||
|
||||
statusLabel === "ok" ||
|
||||
statusLabel === "healthy" ||
|
||||
data.ready === true;
|
||||
const badge = isReady ? "Ready" : "Not Ready";
|
||||
|
||||
const lines: string[] = [
|
||||
`**Orchestrator Status**\n`,
|
||||
`| Field | Value |`,
|
||||
`|---|---|`,
|
||||
`| Status | **${badge}** |`,
|
||||
];
|
||||
|
||||
if (data.version != null) {
|
||||
lines.push(`| Version | \`${data.version}\` |`);
|
||||
}
|
||||
|
||||
if (data.uptime != null) {
|
||||
const uptimeSec = Math.floor(data.uptime);
|
||||
const hours = Math.floor(uptimeSec / 3600);
|
||||
const mins = Math.floor((uptimeSec % 3600) / 60);
|
||||
const secs = uptimeSec % 60;
|
||||
const uptimeStr =
|
||||
hours > 0
|
||||
? `${String(hours)}h ${String(mins)}m ${String(secs)}s`
|
||||
: `${String(mins)}m ${String(secs)}s`;
|
||||
lines.push(`| Uptime | ${uptimeStr} |`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatAgents(raw: unknown): string {
|
||||
let agents: Agent[] = [];
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
agents = raw as Agent[];
|
||||
} else if (raw !== null && typeof raw === "object") {
|
||||
const obj = raw as AgentsResponse;
|
||||
if (obj.error) {
|
||||
return `**Agents**\n\nError: ${obj.error}`;
|
||||
}
|
||||
if (Array.isArray(obj.agents)) {
|
||||
agents = obj.agents;
|
||||
}
|
||||
}
|
||||
|
||||
if (agents.length === 0) {
|
||||
return "**Agents**\n\nNo agents currently running.";
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`**Agents** (${String(agents.length)} total)\n`,
|
||||
`| ID / Key | Status | Type / Channel | Started |`,
|
||||
`|---|---|---|---|`,
|
||||
];
|
||||
|
||||
for (const agent of agents) {
|
||||
const id = agent.id ?? agent.sessionKey ?? "—";
|
||||
const status = agent.agentStatus ?? agent.status ?? "—";
|
||||
const type = agent.type ?? agent.channel ?? "—";
|
||||
const started = agent.startedAt ? new Date(agent.startedAt).toLocaleString() : "—";
|
||||
lines.push(`| \`${id}\` | ${status} | ${type} | ${started} |`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatQueueStats(data: QueueStats): string {
|
||||
if (data.error) {
|
||||
return `**Queue Stats**\n\nError: ${data.error}`;
|
||||
}
|
||||
|
||||
const lines: string[] = [`**Queue Statistics**\n`, `| Metric | Count |`, `|---|---|`];
|
||||
|
||||
const metrics: [string, number | undefined][] = [
|
||||
["Pending", data.pending ?? data.waiting],
|
||||
["Active", data.active],
|
||||
["Delayed", data.delayed],
|
||||
["Completed", data.completed],
|
||||
["Failed", data.failed],
|
||||
];
|
||||
|
||||
for (const [label, value] of metrics) {
|
||||
if (value !== undefined) {
|
||||
lines.push(`| ${label} | ${String(value)} |`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.paused === true) {
|
||||
lines.push("\n_Queue is currently **paused**._");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatAction(command: string, data: ActionResponse): string {
|
||||
if (data.error) {
|
||||
return `**${command}** failed.\n\nError: ${data.error}`;
|
||||
}
|
||||
|
||||
const verb = command === "/pause" ? "paused" : "resumed";
|
||||
const msg = data.message ?? data.status ?? `Queue ${verb} successfully.`;
|
||||
return `**Queue ${verb}**\n\n${msg}`;
|
||||
}
|
||||
|
||||
function formatHelp(): string {
|
||||
const lines: string[] = [
|
||||
"**Available Orchestrator Commands**\n",
|
||||
"| Command | Description |",
|
||||
"|---|---|",
|
||||
];
|
||||
|
||||
for (const cmd of ORCHESTRATOR_COMMANDS) {
|
||||
const name = cmd.aliases ? `${cmd.name} (${cmd.aliases.join(", ")})` : cmd.name;
|
||||
lines.push(`| \`${name}\` | ${cmd.description} |`);
|
||||
}
|
||||
|
||||
lines.push("\n_Commands starting with `/` are routed to the orchestrator instead of the LLM._");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseCommandName(content: string): string | null {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
const parts = trimmed.split(/\s+/);
|
||||
return parts[0]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseOrchestratorCommandsReturn {
|
||||
/**
|
||||
* Returns true if the content looks like an orchestrator command.
|
||||
*/
|
||||
isCommand: (content: string) => boolean;
|
||||
|
||||
/**
|
||||
* Execute an orchestrator command.
|
||||
* Returns a Message with formatted markdown output, or null if not a command.
|
||||
*/
|
||||
executeCommand: (content: string) => Promise<Message | null>;
|
||||
}
|
||||
|
||||
export function useOrchestratorCommands(): UseOrchestratorCommandsReturn {
|
||||
const isCommand = useCallback((content: string): boolean => {
|
||||
return content.trim().startsWith("/");
|
||||
}, []);
|
||||
|
||||
const executeCommand = useCallback(async (content: string): Promise<Message | null> => {
|
||||
const command = parseCommandName(content);
|
||||
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// /help — local, no network
|
||||
if (command === "/help") {
|
||||
return makeMessage(formatHelp());
|
||||
}
|
||||
|
||||
// /status
|
||||
if (command === "/status") {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/health", { method: "GET" });
|
||||
const data = (await res.json()) as HealthResponse;
|
||||
return makeMessage(formatStatus(data));
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : "Network error";
|
||||
return errorMessage("/status", detail);
|
||||
}
|
||||
}
|
||||
|
||||
// /agents
|
||||
if (command === "/agents") {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/agents", { method: "GET" });
|
||||
const data: unknown = await res.json();
|
||||
return makeMessage(formatAgents(data));
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : "Network error";
|
||||
return errorMessage("/agents", detail);
|
||||
}
|
||||
}
|
||||
|
||||
// /jobs or /queue
|
||||
if (command === "/jobs" || command === "/queue") {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/queue/stats", { method: "GET" });
|
||||
const data = (await res.json()) as QueueStats;
|
||||
return makeMessage(formatQueueStats(data));
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : "Network error";
|
||||
return errorMessage(command, detail);
|
||||
}
|
||||
}
|
||||
|
||||
// /pause
|
||||
if (command === "/pause") {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/queue/pause", { method: "POST" });
|
||||
const data = (await res.json()) as ActionResponse;
|
||||
return makeMessage(formatAction("/pause", data));
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : "Network error";
|
||||
return errorMessage("/pause", detail);
|
||||
}
|
||||
}
|
||||
|
||||
// /resume
|
||||
if (command === "/resume") {
|
||||
try {
|
||||
const res = await fetch("/api/orchestrator/queue/resume", { method: "POST" });
|
||||
const data = (await res.json()) as ActionResponse;
|
||||
return makeMessage(formatAction("/resume", data));
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : "Network error";
|
||||
return errorMessage("/resume", detail);
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown command — show help hint
|
||||
return makeMessage(
|
||||
`Unknown command: \`${command}\`\n\nType \`/help\` to see available commands.`
|
||||
);
|
||||
}, []);
|
||||
|
||||
return { isCommand, executeCommand };
|
||||
}
|
||||
462
apps/web/src/hooks/useTerminal.test.ts
Normal file
462
apps/web/src/hooks/useTerminal.test.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* @file useTerminal.test.ts
|
||||
* @description Unit tests for the useTerminal hook
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useTerminal } from "./useTerminal";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
// ==========================================
|
||||
// Mock socket.io-client
|
||||
// ==========================================
|
||||
|
||||
vi.mock("socket.io-client");
|
||||
|
||||
// ==========================================
|
||||
// Mock lib/config
|
||||
// ==========================================
|
||||
|
||||
vi.mock("@/lib/config", () => ({
|
||||
API_BASE_URL: "http://localhost:3001",
|
||||
}));
|
||||
|
||||
// ==========================================
|
||||
// Helpers
|
||||
// ==========================================
|
||||
|
||||
interface MockSocket {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
emit: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
describe("useTerminal", () => {
|
||||
let mockSocket: MockSocket;
|
||||
let socketEventHandlers: Record<string, (data: unknown) => void>;
|
||||
let mockIo: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
socketEventHandlers = {};
|
||||
|
||||
mockSocket = {
|
||||
on: vi.fn((event: string, handler: (data: unknown) => void) => {
|
||||
socketEventHandlers[event] = handler;
|
||||
return mockSocket;
|
||||
}),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
connected: true,
|
||||
};
|
||||
|
||||
const socketIo = await import("socket.io-client");
|
||||
mockIo = vi.mocked(socketIo.io);
|
||||
mockIo.mockReturnValue(mockSocket as unknown as Socket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Connection
|
||||
// ==========================================
|
||||
|
||||
describe("connection lifecycle", () => {
|
||||
it("should connect to the /terminal namespace with auth token", () => {
|
||||
renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockIo).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/terminal"),
|
||||
expect.objectContaining({
|
||||
auth: { token: "test-token" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should start disconnected and update when connected event fires", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update sessionId when terminal:created event fires", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-abc",
|
||||
name: "main",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBe("session-abc");
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear sessionId when disconnect event fires", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-abc",
|
||||
name: "main",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBe("session-abc");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.disconnect?.(undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.sessionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should set connectionError when connect_error fires", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect_error?.(new Error("Connection refused"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.connectionError).toBe("Connection refused");
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not connect when token is empty", () => {
|
||||
renderHook(() =>
|
||||
useTerminal({
|
||||
token: "",
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockIo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Output and exit callbacks
|
||||
// ==========================================
|
||||
|
||||
describe("event callbacks", () => {
|
||||
it("should call onOutput when terminal:output fires", () => {
|
||||
const onOutput = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
onOutput,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:output"]?.({
|
||||
sessionId: "session-abc",
|
||||
data: "hello world\r\n",
|
||||
});
|
||||
});
|
||||
|
||||
expect(onOutput).toHaveBeenCalledWith("session-abc", "hello world\r\n");
|
||||
});
|
||||
|
||||
it("should call onExit when terminal:exit fires and clear sessionId", async () => {
|
||||
const onExit = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
onExit,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-abc",
|
||||
name: "main",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:exit"]?.({
|
||||
sessionId: "session-abc",
|
||||
exitCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onExit).toHaveBeenCalledWith({ sessionId: "session-abc", exitCode: 0 });
|
||||
expect(result.current.sessionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onError when terminal:error fires", () => {
|
||||
const onError = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
onError,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:error"]?.({
|
||||
message: "PTY spawn failed",
|
||||
});
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith("PTY spawn failed");
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Control functions
|
||||
// ==========================================
|
||||
|
||||
describe("createSession", () => {
|
||||
it("should emit terminal:create with options when connected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.createSession({ cols: 120, rows: 40, name: "test" });
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:create", {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
name: "test",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not emit terminal:create when disconnected", () => {
|
||||
mockSocket.connected = false;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.createSession({ cols: 80, rows: 24 });
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:create", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendInput", () => {
|
||||
it("should emit terminal:input with sessionId and data", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-abc",
|
||||
name: "main",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendInput("ls -la\n");
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:input", {
|
||||
sessionId: "session-abc",
|
||||
data: "ls -la\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not emit when no sessionId is set", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendInput("ls -la\n");
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:input", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe("resize", () => {
|
||||
it("should emit terminal:resize with sessionId, cols, and rows", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-abc",
|
||||
name: "main",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.resize(100, 30);
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:resize", {
|
||||
sessionId: "session-abc",
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeSession", () => {
|
||||
it("should emit terminal:close and clear sessionId", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-abc",
|
||||
name: "main",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBe("session-abc");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closeSession();
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", {
|
||||
sessionId: "session-abc",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Cleanup
|
||||
// ==========================================
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("should disconnect socket on unmount", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
})
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockSocket.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should emit terminal:close for active session on unmount", () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useTerminal({
|
||||
token: "test-token",
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-abc",
|
||||
name: "main",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.sessionId).toBe("session-abc");
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", {
|
||||
sessionId: "session-abc",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
294
apps/web/src/hooks/useTerminal.ts
Normal file
294
apps/web/src/hooks/useTerminal.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* useTerminal hook
|
||||
*
|
||||
* Manages a WebSocket connection to the /terminal namespace and a PTY terminal session.
|
||||
* Follows the same patterns as useVoiceInput and useWebSocket.
|
||||
*
|
||||
* Protocol (from terminal.gateway.ts):
|
||||
* 1. Connect with auth token in handshake
|
||||
* 2. Emit terminal:create → receive terminal:created { sessionId, name, cols, rows }
|
||||
* 3. Emit terminal:input { sessionId, data } to send keystrokes
|
||||
* 4. Receive terminal:output { sessionId, data } for stdout/stderr
|
||||
* 5. Emit terminal:resize { sessionId, cols, rows } on window resize
|
||||
* 6. Emit terminal:close { sessionId } to terminate the PTY
|
||||
* 7. Receive terminal:exit { sessionId, exitCode, signal } on PTY exit
|
||||
* 8. Receive terminal:error { message } on errors
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import { io } from "socket.io-client";
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
name?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
sessionId: string;
|
||||
name: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface TerminalExitEvent {
|
||||
sessionId: string;
|
||||
exitCode: number;
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
export interface UseTerminalOptions {
|
||||
/** Authentication token for WebSocket handshake */
|
||||
token: string;
|
||||
/** Callback fired when terminal output is received */
|
||||
onOutput?: (sessionId: string, data: string) => void;
|
||||
/** Callback fired when a terminal session exits */
|
||||
onExit?: (event: TerminalExitEvent) => void;
|
||||
/** Callback fired on terminal errors */
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface UseTerminalReturn {
|
||||
/** Whether the WebSocket is connected */
|
||||
isConnected: boolean;
|
||||
/** The current terminal session ID, or null if no session is active */
|
||||
sessionId: string | null;
|
||||
/** Create a new PTY session */
|
||||
createSession: (options?: CreateSessionOptions) => void;
|
||||
/** Send input data to the terminal */
|
||||
sendInput: (data: string) => void;
|
||||
/** Resize the terminal PTY */
|
||||
resize: (cols: number, rows: number) => void;
|
||||
/** Close the current PTY session */
|
||||
closeSession: () => void;
|
||||
/** Connection error message, if any */
|
||||
connectionError: string | null;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Payload shapes matching terminal.dto.ts
|
||||
// ==========================================
|
||||
|
||||
interface TerminalCreatedPayload {
|
||||
sessionId: string;
|
||||
name: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
interface TerminalOutputPayload {
|
||||
sessionId: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface TerminalExitPayload {
|
||||
sessionId: string;
|
||||
exitCode: number;
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
interface TerminalErrorPayload {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Security validation
|
||||
// ==========================================
|
||||
|
||||
function validateWebSocketSecurity(url: string): void {
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const isSecure = url.startsWith("https://") || url.startsWith("wss://");
|
||||
|
||||
if (isProduction && !isSecure) {
|
||||
console.warn(
|
||||
"[Security Warning] Terminal WebSocket using insecure protocol (ws://). " +
|
||||
"Authentication tokens may be exposed. Use wss:// in production."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Hook
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Hook for managing a real PTY terminal session over WebSocket.
|
||||
*
|
||||
* @param options - Configuration including auth token and event callbacks
|
||||
* @returns Terminal state and control functions
|
||||
*/
|
||||
export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
|
||||
const { token, onOutput, onExit, onError } = options;
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
// Keep callbacks in refs to avoid stale closures without causing reconnects
|
||||
const onOutputRef = useRef(onOutput);
|
||||
const onExitRef = useRef(onExit);
|
||||
const onErrorRef = useRef(onError);
|
||||
|
||||
useEffect(() => {
|
||||
onOutputRef.current = onOutput;
|
||||
}, [onOutput]);
|
||||
|
||||
useEffect(() => {
|
||||
onExitRef.current = onExit;
|
||||
}, [onExit]);
|
||||
|
||||
useEffect(() => {
|
||||
onErrorRef.current = onError;
|
||||
}, [onError]);
|
||||
|
||||
// Connect to the /terminal namespace
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = API_BASE_URL;
|
||||
validateWebSocketSecurity(wsUrl);
|
||||
|
||||
setConnectionError(null);
|
||||
|
||||
const socket = io(`${wsUrl}/terminal`, {
|
||||
auth: { token },
|
||||
transports: ["websocket", "polling"],
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
const handleConnect = (): void => {
|
||||
setIsConnected(true);
|
||||
setConnectionError(null);
|
||||
};
|
||||
|
||||
const handleDisconnect = (): void => {
|
||||
setIsConnected(false);
|
||||
setSessionId(null);
|
||||
sessionIdRef.current = null;
|
||||
};
|
||||
|
||||
const handleConnectError = (error: Error): void => {
|
||||
setConnectionError(error.message || "Terminal connection failed");
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
const handleTerminalCreated = (payload: TerminalCreatedPayload): void => {
|
||||
setSessionId(payload.sessionId);
|
||||
sessionIdRef.current = payload.sessionId;
|
||||
};
|
||||
|
||||
const handleTerminalOutput = (payload: TerminalOutputPayload): void => {
|
||||
onOutputRef.current?.(payload.sessionId, payload.data);
|
||||
};
|
||||
|
||||
const handleTerminalExit = (payload: TerminalExitPayload): void => {
|
||||
onExitRef.current?.(payload);
|
||||
setSessionId(null);
|
||||
sessionIdRef.current = null;
|
||||
};
|
||||
|
||||
const handleTerminalError = (payload: TerminalErrorPayload): void => {
|
||||
onErrorRef.current?.(payload.message);
|
||||
};
|
||||
|
||||
socket.on("connect", handleConnect);
|
||||
socket.on("disconnect", handleDisconnect);
|
||||
socket.on("connect_error", handleConnectError);
|
||||
socket.on("terminal:created", handleTerminalCreated);
|
||||
socket.on("terminal:output", handleTerminalOutput);
|
||||
socket.on("terminal:exit", handleTerminalExit);
|
||||
socket.on("terminal:error", handleTerminalError);
|
||||
|
||||
return (): void => {
|
||||
socket.off("connect", handleConnect);
|
||||
socket.off("disconnect", handleDisconnect);
|
||||
socket.off("connect_error", handleConnectError);
|
||||
socket.off("terminal:created", handleTerminalCreated);
|
||||
socket.off("terminal:output", handleTerminalOutput);
|
||||
socket.off("terminal:exit", handleTerminalExit);
|
||||
socket.off("terminal:error", handleTerminalError);
|
||||
|
||||
// Close active session before disconnecting
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
if (currentSessionId) {
|
||||
socket.emit("terminal:close", { sessionId: currentSessionId });
|
||||
}
|
||||
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const createSession = useCallback((createOptions: CreateSessionOptions = {}): void => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (createOptions.name !== undefined) payload.name = createOptions.name;
|
||||
if (createOptions.cols !== undefined) payload.cols = createOptions.cols;
|
||||
if (createOptions.rows !== undefined) payload.rows = createOptions.rows;
|
||||
if (createOptions.cwd !== undefined) payload.cwd = createOptions.cwd;
|
||||
|
||||
socket.emit("terminal:create", payload);
|
||||
}, []);
|
||||
|
||||
const sendInput = useCallback((data: string): void => {
|
||||
const socket = socketRef.current;
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
|
||||
if (!socket?.connected || !currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit("terminal:input", { sessionId: currentSessionId, data });
|
||||
}, []);
|
||||
|
||||
const resize = useCallback((cols: number, rows: number): void => {
|
||||
const socket = socketRef.current;
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
|
||||
if (!socket?.connected || !currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit("terminal:resize", { sessionId: currentSessionId, cols, rows });
|
||||
}, []);
|
||||
|
||||
const closeSession = useCallback((): void => {
|
||||
const socket = socketRef.current;
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
|
||||
if (!socket?.connected || !currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit("terminal:close", { sessionId: currentSessionId });
|
||||
setSessionId(null);
|
||||
sessionIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
sessionId,
|
||||
createSession,
|
||||
sendInput,
|
||||
resize,
|
||||
closeSession,
|
||||
connectionError,
|
||||
};
|
||||
}
|
||||
690
apps/web/src/hooks/useTerminalSessions.test.ts
Normal file
690
apps/web/src/hooks/useTerminalSessions.test.ts
Normal file
@@ -0,0 +1,690 @@
|
||||
/**
|
||||
* @file useTerminalSessions.test.ts
|
||||
* @description Unit tests for the useTerminalSessions hook
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useTerminalSessions } from "./useTerminalSessions";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
// ==========================================
|
||||
// Mock socket.io-client
|
||||
// ==========================================
|
||||
|
||||
vi.mock("socket.io-client");
|
||||
|
||||
// ==========================================
|
||||
// Mock lib/config
|
||||
// ==========================================
|
||||
|
||||
vi.mock("@/lib/config", () => ({
|
||||
API_BASE_URL: "http://localhost:3001",
|
||||
}));
|
||||
|
||||
// ==========================================
|
||||
// Helpers
|
||||
// ==========================================
|
||||
|
||||
interface MockSocket {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
emit: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
describe("useTerminalSessions", () => {
|
||||
let mockSocket: MockSocket;
|
||||
let socketEventHandlers: Record<string, (data: unknown) => void>;
|
||||
let mockIo: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
socketEventHandlers = {};
|
||||
|
||||
mockSocket = {
|
||||
on: vi.fn((event: string, handler: (data: unknown) => void) => {
|
||||
socketEventHandlers[event] = handler;
|
||||
return mockSocket;
|
||||
}),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
connected: true,
|
||||
};
|
||||
|
||||
const socketIo = await import("socket.io-client");
|
||||
mockIo = vi.mocked(socketIo.io);
|
||||
mockIo.mockReturnValue(mockSocket as unknown as Socket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Connection lifecycle
|
||||
// ==========================================
|
||||
|
||||
describe("connection lifecycle", () => {
|
||||
it("should connect to the /terminal namespace with auth token", () => {
|
||||
renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
expect(mockIo).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/terminal"),
|
||||
expect.objectContaining({
|
||||
auth: { token: "test-token" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should start disconnected", () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("should update isConnected when connect event fires", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should set connectionError when connect_error fires", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect_error?.(new Error("Connection refused"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.connectionError).toBe("Connection refused");
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not connect when token is empty", () => {
|
||||
renderHook(() => useTerminalSessions({ token: "" }));
|
||||
|
||||
expect(mockIo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should disconnect socket on unmount", () => {
|
||||
const { unmount } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockSocket.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Session creation
|
||||
// ==========================================
|
||||
|
||||
describe("createSession", () => {
|
||||
it("should emit terminal:create when connected", () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.createSession({ name: "bash", cols: 120, rows: 40 });
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:create", {
|
||||
name: "bash",
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not emit terminal:create when disconnected", () => {
|
||||
mockSocket.connected = false;
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
result.current.createSession();
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:create", expect.anything());
|
||||
});
|
||||
|
||||
it("should add session to sessions map when terminal:created fires", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-1",
|
||||
name: "Terminal 1",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.has("session-1")).toBe(true);
|
||||
expect(result.current.sessions.get("session-1")?.name).toBe("Terminal 1");
|
||||
expect(result.current.sessions.get("session-1")?.status).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
it("should set first created session as active", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-1",
|
||||
name: "Terminal 1",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSessionId).toBe("session-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not change active session when a second session is created", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-1",
|
||||
name: "Terminal 1",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSessionId).toBe("session-1");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-2",
|
||||
name: "Terminal 2",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.size).toBe(2);
|
||||
// Active session should remain session-1
|
||||
expect(result.current.activeSessionId).toBe("session-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("should manage multiple sessions in the sessions map", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-1",
|
||||
name: "Terminal 1",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-2",
|
||||
name: "Terminal 2",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.size).toBe(2);
|
||||
expect(result.current.sessions.has("session-1")).toBe(true);
|
||||
expect(result.current.sessions.has("session-2")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Session close
|
||||
// ==========================================
|
||||
|
||||
describe("closeSession", () => {
|
||||
it("should emit terminal:close and remove session from map", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-1",
|
||||
name: "Terminal 1",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.has("session-1")).toBe(true);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closeSession("session-1");
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", {
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.has("session-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should switch active session to another when active is closed", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-1",
|
||||
name: "Terminal 1",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-2",
|
||||
name: "Terminal 2",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSessionId).toBe("session-1");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closeSession("session-1");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should switch to session-2
|
||||
expect(result.current.activeSessionId).toBe("session-2");
|
||||
expect(result.current.sessions.has("session-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should set activeSessionId to null when last session is closed", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-1",
|
||||
name: "Terminal 1",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSessionId).toBe("session-1");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closeSession("session-1");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.size).toBe(0);
|
||||
expect(result.current.activeSessionId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Rename session
|
||||
// ==========================================
|
||||
|
||||
describe("renameSession", () => {
|
||||
it("should update the session name in the sessions map", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-1",
|
||||
name: "Terminal 1",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.get("session-1")?.name).toBe("Terminal 1");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.renameSession("session-1", "My Custom Shell");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.get("session-1")?.name).toBe("My Custom Shell");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not affect other session names", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-1",
|
||||
name: "Terminal 1",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-2",
|
||||
name: "Terminal 2",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.renameSession("session-1", "Custom");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.get("session-1")?.name).toBe("Custom");
|
||||
expect(result.current.sessions.get("session-2")?.name).toBe("Terminal 2");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// setActiveSession
|
||||
// ==========================================
|
||||
|
||||
describe("setActiveSession", () => {
|
||||
it("should update activeSessionId", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-1",
|
||||
name: "Terminal 1",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-2",
|
||||
name: "Terminal 2",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSessionId).toBe("session-1");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveSession("session-2");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSessionId).toBe("session-2");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// sendInput
|
||||
// ==========================================
|
||||
|
||||
describe("sendInput", () => {
|
||||
it("should emit terminal:input with sessionId and data", () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendInput("session-1", "ls -la\n");
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:input", {
|
||||
sessionId: "session-1",
|
||||
data: "ls -la\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not emit when disconnected", () => {
|
||||
mockSocket.connected = false;
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
result.current.sendInput("session-1", "ls\n");
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:input", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// resize
|
||||
// ==========================================
|
||||
|
||||
describe("resize", () => {
|
||||
it("should emit terminal:resize with sessionId, cols, and rows", () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.connect?.(undefined);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.resize("session-1", 120, 40);
|
||||
});
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:resize", {
|
||||
sessionId: "session-1",
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Output callback routing
|
||||
// ==========================================
|
||||
|
||||
describe("registerOutputCallback", () => {
|
||||
it("should call the registered callback when terminal:output fires for that session", () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
const cb = vi.fn();
|
||||
|
||||
act(() => {
|
||||
result.current.registerOutputCallback("session-1", cb);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:output"]?.({
|
||||
sessionId: "session-1",
|
||||
data: "hello world\r\n",
|
||||
});
|
||||
});
|
||||
|
||||
expect(cb).toHaveBeenCalledWith("hello world\r\n");
|
||||
});
|
||||
|
||||
it("should not call callback for a different session", () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
const cbSession1 = vi.fn();
|
||||
const cbSession2 = vi.fn();
|
||||
|
||||
act(() => {
|
||||
result.current.registerOutputCallback("session-1", cbSession1);
|
||||
result.current.registerOutputCallback("session-2", cbSession2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:output"]?.({
|
||||
sessionId: "session-1",
|
||||
data: "output for session 1",
|
||||
});
|
||||
});
|
||||
|
||||
expect(cbSession1).toHaveBeenCalledWith("output for session 1");
|
||||
expect(cbSession2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should stop calling callback after unsubscribing", () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
const cb = vi.fn();
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
act(() => {
|
||||
unsubscribe = result.current.registerOutputCallback("session-1", cb);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
unsubscribe?.();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:output"]?.({
|
||||
sessionId: "session-1",
|
||||
data: "should not arrive",
|
||||
});
|
||||
});
|
||||
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should support multiple callbacks for the same session", () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
|
||||
act(() => {
|
||||
result.current.registerOutputCallback("session-1", cb1);
|
||||
result.current.registerOutputCallback("session-1", cb2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:output"]?.({
|
||||
sessionId: "session-1",
|
||||
data: "broadcast",
|
||||
});
|
||||
});
|
||||
|
||||
expect(cb1).toHaveBeenCalledWith("broadcast");
|
||||
expect(cb2).toHaveBeenCalledWith("broadcast");
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Exit event
|
||||
// ==========================================
|
||||
|
||||
describe("terminal:exit handling", () => {
|
||||
it("should mark session as exited when terminal:exit fires", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-1",
|
||||
name: "Terminal 1",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.get("session-1")?.status).toBe("active");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:exit"]?.({
|
||||
sessionId: "session-1",
|
||||
exitCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.get("session-1")?.status).toBe("exited");
|
||||
expect(result.current.sessions.get("session-1")?.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not remove the session from the map on exit", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-1",
|
||||
name: "Terminal 1",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:exit"]?.({
|
||||
sessionId: "session-1",
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Session remains in map — user can restart or close it manually
|
||||
expect(result.current.sessions.has("session-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Disconnect handling
|
||||
// ==========================================
|
||||
|
||||
describe("disconnect handling", () => {
|
||||
it("should mark all active sessions as exited on disconnect", async () => {
|
||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-1",
|
||||
name: "Terminal 1",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
socketEventHandlers["terminal:created"]?.({
|
||||
sessionId: "session-2",
|
||||
name: "Terminal 2",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions.size).toBe(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
socketEventHandlers.disconnect?.(undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.sessions.get("session-1")?.status).toBe("exited");
|
||||
expect(result.current.sessions.get("session-2")?.status).toBe("exited");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
381
apps/web/src/hooks/useTerminalSessions.ts
Normal file
381
apps/web/src/hooks/useTerminalSessions.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* useTerminalSessions hook
|
||||
*
|
||||
* Manages multiple PTY terminal sessions over a single WebSocket connection
|
||||
* to the /terminal namespace. Supports creating, closing, renaming, and switching
|
||||
* between sessions, with per-session output callback multiplexing.
|
||||
*
|
||||
* Protocol (from terminal.gateway.ts):
|
||||
* 1. Connect with auth token in handshake
|
||||
* 2. Emit terminal:create { name?, cols?, rows? } → receive terminal:created { sessionId, name, cols, rows }
|
||||
* 3. Emit terminal:input { sessionId, data } to send keystrokes
|
||||
* 4. Receive terminal:output { sessionId, data } for stdout/stderr
|
||||
* 5. Emit terminal:resize { sessionId, cols, rows } on window resize
|
||||
* 6. Emit terminal:close { sessionId } to terminate the PTY
|
||||
* 7. Receive terminal:exit { sessionId, exitCode, signal } on PTY exit
|
||||
* 8. Receive terminal:error { message } on errors
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import { io } from "socket.io-client";
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
export type SessionStatus = "active" | "exited";
|
||||
|
||||
export interface SessionInfo {
|
||||
/** Session identifier returned by the server */
|
||||
sessionId: string;
|
||||
/** Human-readable tab label */
|
||||
name: string;
|
||||
/** Whether the PTY process is still running */
|
||||
status: SessionStatus;
|
||||
/** Exit code, populated when status === 'exited' */
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
/** Optional label for the new session */
|
||||
name?: string;
|
||||
/** Terminal columns */
|
||||
cols?: number;
|
||||
/** Terminal rows */
|
||||
rows?: number;
|
||||
/** Working directory */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface UseTerminalSessionsOptions {
|
||||
/** Authentication token for WebSocket handshake */
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface UseTerminalSessionsReturn {
|
||||
/** Map of sessionId → SessionInfo */
|
||||
sessions: Map<string, SessionInfo>;
|
||||
/** Currently active (visible) session id, or null if none */
|
||||
activeSessionId: string | null;
|
||||
/** Whether the WebSocket is connected */
|
||||
isConnected: boolean;
|
||||
/** Connection error message, if any */
|
||||
connectionError: string | null;
|
||||
/** Create a new PTY session */
|
||||
createSession: (options?: CreateSessionOptions) => void;
|
||||
/** Close an existing PTY session */
|
||||
closeSession: (sessionId: string) => void;
|
||||
/** Rename a session (local label only, not persisted to server) */
|
||||
renameSession: (sessionId: string, name: string) => void;
|
||||
/** Switch the visible session */
|
||||
setActiveSession: (sessionId: string) => void;
|
||||
/** Send keyboard input to a session */
|
||||
sendInput: (sessionId: string, data: string) => void;
|
||||
/** Notify the server of a terminal resize */
|
||||
resize: (sessionId: string, cols: number, rows: number) => void;
|
||||
/**
|
||||
* Register a callback that receives output data for a specific session.
|
||||
* Returns an unsubscribe function — call it during cleanup.
|
||||
*/
|
||||
registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Payload shapes matching terminal.dto.ts
|
||||
// ==========================================
|
||||
|
||||
interface TerminalCreatedPayload {
|
||||
sessionId: string;
|
||||
name: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
interface TerminalOutputPayload {
|
||||
sessionId: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface TerminalExitPayload {
|
||||
sessionId: string;
|
||||
exitCode: number;
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
interface TerminalErrorPayload {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Security validation
|
||||
// ==========================================
|
||||
|
||||
function validateWebSocketSecurity(url: string): void {
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const isSecure = url.startsWith("https://") || url.startsWith("wss://");
|
||||
|
||||
if (isProduction && !isSecure) {
|
||||
console.warn(
|
||||
"[Security Warning] Terminal WebSocket using insecure protocol (ws://). " +
|
||||
"Authentication tokens may be exposed. Use wss:// in production."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Hook
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Hook for managing multiple PTY terminal sessions over a single WebSocket connection.
|
||||
*
|
||||
* @param options - Configuration including auth token
|
||||
* @returns Multi-session terminal state and control functions
|
||||
*/
|
||||
export function useTerminalSessions(
|
||||
options: UseTerminalSessionsOptions
|
||||
): UseTerminalSessionsReturn {
|
||||
const { token } = options;
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
// Per-session output callback registry; keyed by sessionId
|
||||
const outputCallbacksRef = useRef<Map<string, Set<(data: string) => void>>>(new Map());
|
||||
|
||||
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map());
|
||||
const [activeSessionId, setActiveSessionIdState] = useState<string | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
// ==========================================
|
||||
// Auto-select first available session when active becomes null
|
||||
// ==========================================
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSessionId === null && sessions.size > 0) {
|
||||
const firstId = sessions.keys().next().value;
|
||||
if (firstId !== undefined) {
|
||||
setActiveSessionIdState(firstId);
|
||||
}
|
||||
}
|
||||
}, [activeSessionId, sessions]);
|
||||
|
||||
// ==========================================
|
||||
// WebSocket connection
|
||||
// ==========================================
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = API_BASE_URL;
|
||||
validateWebSocketSecurity(wsUrl);
|
||||
|
||||
setConnectionError(null);
|
||||
|
||||
const socket = io(`${wsUrl}/terminal`, {
|
||||
auth: { token },
|
||||
transports: ["websocket", "polling"],
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
const handleConnect = (): void => {
|
||||
setIsConnected(true);
|
||||
setConnectionError(null);
|
||||
};
|
||||
|
||||
const handleDisconnect = (): void => {
|
||||
setIsConnected(false);
|
||||
// Sessions remain in the Map but are no longer interactive
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [id, info] of next) {
|
||||
if (info.status === "active") {
|
||||
next.set(id, { ...info, status: "exited" });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConnectError = (error: Error): void => {
|
||||
setConnectionError(error.message || "Terminal connection failed");
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
const handleTerminalCreated = (payload: TerminalCreatedPayload): void => {
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(payload.sessionId, {
|
||||
sessionId: payload.sessionId,
|
||||
name: payload.name,
|
||||
status: "active",
|
||||
});
|
||||
return next;
|
||||
});
|
||||
// Set as active session if none is currently active
|
||||
setActiveSessionIdState((prev) => prev ?? payload.sessionId);
|
||||
};
|
||||
|
||||
const handleTerminalOutput = (payload: TerminalOutputPayload): void => {
|
||||
const callbacks = outputCallbacksRef.current.get(payload.sessionId);
|
||||
if (callbacks) {
|
||||
for (const cb of callbacks) {
|
||||
cb(payload.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerminalExit = (payload: TerminalExitPayload): void => {
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev);
|
||||
const session = next.get(payload.sessionId);
|
||||
if (session) {
|
||||
next.set(payload.sessionId, {
|
||||
...session,
|
||||
status: "exited",
|
||||
exitCode: payload.exitCode,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTerminalError = (payload: TerminalErrorPayload): void => {
|
||||
console.error("[Terminal] Error:", payload.message);
|
||||
};
|
||||
|
||||
socket.on("connect", handleConnect);
|
||||
socket.on("disconnect", handleDisconnect);
|
||||
socket.on("connect_error", handleConnectError);
|
||||
socket.on("terminal:created", handleTerminalCreated);
|
||||
socket.on("terminal:output", handleTerminalOutput);
|
||||
socket.on("terminal:exit", handleTerminalExit);
|
||||
socket.on("terminal:error", handleTerminalError);
|
||||
|
||||
return (): void => {
|
||||
socket.off("connect", handleConnect);
|
||||
socket.off("disconnect", handleDisconnect);
|
||||
socket.off("connect_error", handleConnectError);
|
||||
socket.off("terminal:created", handleTerminalCreated);
|
||||
socket.off("terminal:output", handleTerminalOutput);
|
||||
socket.off("terminal:exit", handleTerminalExit);
|
||||
socket.off("terminal:error", handleTerminalError);
|
||||
|
||||
// Close all active sessions before disconnecting
|
||||
const currentSessions = sessions;
|
||||
for (const [id, info] of currentSessions) {
|
||||
if (info.status === "active") {
|
||||
socket.emit("terminal:close", { sessionId: id });
|
||||
}
|
||||
}
|
||||
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
// Intentional: token is the only dep that should trigger reconnection
|
||||
}, [token]);
|
||||
|
||||
// ==========================================
|
||||
// Control functions
|
||||
// ==========================================
|
||||
|
||||
const createSession = useCallback((createOptions: CreateSessionOptions = {}): void => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (createOptions.name !== undefined) payload.name = createOptions.name;
|
||||
if (createOptions.cols !== undefined) payload.cols = createOptions.cols;
|
||||
if (createOptions.rows !== undefined) payload.rows = createOptions.rows;
|
||||
if (createOptions.cwd !== undefined) payload.cwd = createOptions.cwd;
|
||||
|
||||
socket.emit("terminal:create", payload);
|
||||
}, []);
|
||||
|
||||
const closeSession = useCallback((sessionId: string): void => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.connected) {
|
||||
socket.emit("terminal:close", { sessionId });
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(sessionId);
|
||||
return next;
|
||||
});
|
||||
|
||||
// If closing the active session, activeSessionId becomes null
|
||||
// and the auto-select useEffect will pick the first remaining session
|
||||
setActiveSessionIdState((prev) => (prev === sessionId ? null : prev));
|
||||
}, []);
|
||||
|
||||
const renameSession = useCallback((sessionId: string, name: string): void => {
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev);
|
||||
const session = next.get(sessionId);
|
||||
if (session) {
|
||||
next.set(sessionId, { ...session, name });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setActiveSession = useCallback((sessionId: string): void => {
|
||||
setActiveSessionIdState(sessionId);
|
||||
}, []);
|
||||
|
||||
const sendInput = useCallback((sessionId: string, data: string): void => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket?.connected) {
|
||||
return;
|
||||
}
|
||||
socket.emit("terminal:input", { sessionId, data });
|
||||
}, []);
|
||||
|
||||
const resize = useCallback((sessionId: string, cols: number, rows: number): void => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket?.connected) {
|
||||
return;
|
||||
}
|
||||
socket.emit("terminal:resize", { sessionId, cols, rows });
|
||||
}, []);
|
||||
|
||||
const registerOutputCallback = useCallback(
|
||||
(sessionId: string, cb: (data: string) => void): (() => void) => {
|
||||
const registry = outputCallbacksRef.current;
|
||||
if (!registry.has(sessionId)) {
|
||||
registry.set(sessionId, new Set());
|
||||
}
|
||||
// Safe: we just ensured the key exists
|
||||
const callbackSet = registry.get(sessionId);
|
||||
if (callbackSet) {
|
||||
callbackSet.add(cb);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
registry.get(sessionId)?.delete(cb);
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
isConnected,
|
||||
connectionError,
|
||||
createSession,
|
||||
closeSession,
|
||||
renameSession,
|
||||
setActiveSession,
|
||||
sendInput,
|
||||
resize,
|
||||
registerOutputCallback,
|
||||
};
|
||||
}
|
||||
@@ -47,6 +47,7 @@ describe("useWebSocket", (): void => {
|
||||
expect(io).toHaveBeenCalledWith(expect.any(String), {
|
||||
auth: { token },
|
||||
query: { workspaceId },
|
||||
withCredentials: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -97,9 +97,12 @@ export function useWebSocket(
|
||||
setConnectionError(null);
|
||||
|
||||
// Create socket connection
|
||||
// withCredentials sends session cookies cross-origin so the gateway can
|
||||
// authenticate via cookie when no explicit token is provided.
|
||||
const newSocket = io(wsUrl, {
|
||||
auth: { token },
|
||||
query: { workspaceId },
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
setSocket(newSocket);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Handles LLM chat interactions via /api/llm/chat
|
||||
*/
|
||||
|
||||
import { apiPost } from "./client";
|
||||
import { apiPost, fetchCsrfToken, getCsrfToken } from "./client";
|
||||
import { API_BASE_URL } from "../config";
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant";
|
||||
@@ -31,6 +32,19 @@ export interface ChatResponse {
|
||||
evalCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed SSE data chunk from the LLM stream
|
||||
*/
|
||||
interface SseChunk {
|
||||
error?: string;
|
||||
message?: {
|
||||
role: string;
|
||||
content: string;
|
||||
};
|
||||
model?: string;
|
||||
done?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat message to the LLM
|
||||
*/
|
||||
@@ -39,19 +53,122 @@ export async function sendChatMessage(request: ChatRequest): Promise<ChatRespons
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a chat message from the LLM (not implemented yet)
|
||||
* TODO: Implement streaming support
|
||||
* Get or refresh the CSRF token for streaming requests.
|
||||
*/
|
||||
async function ensureCsrfTokenForStream(): Promise<string> {
|
||||
const existing = getCsrfToken();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return fetchCsrfToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a chat message from the LLM using SSE over fetch.
|
||||
*
|
||||
* The backend accepts stream: true in the request body and responds with
|
||||
* Server-Sent Events:
|
||||
* data: {"message":{"content":"token"},...}\n\n for each token
|
||||
* data: [DONE]\n\n when the stream is complete
|
||||
* data: {"error":"message"}\n\n on error
|
||||
*
|
||||
* @param request - Chat request (stream field will be forced to true)
|
||||
* @param onChunk - Called with each token string as it arrives
|
||||
* @param onComplete - Called when the stream finishes successfully
|
||||
* @param onError - Called if the stream encounters an error
|
||||
* @param signal - Optional AbortSignal for cancellation
|
||||
*/
|
||||
export function streamChatMessage(
|
||||
request: ChatRequest,
|
||||
onChunk: (chunk: string) => void,
|
||||
onComplete: () => void,
|
||||
onError: (error: Error) => void
|
||||
onError: (error: Error) => void,
|
||||
signal?: AbortSignal
|
||||
): void {
|
||||
// Streaming implementation would go here
|
||||
void request;
|
||||
void onChunk;
|
||||
void onComplete;
|
||||
void onError;
|
||||
throw new Error("Streaming not implemented yet");
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
const csrfToken = await ensureCsrfTokenForStream();
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/llm/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ ...request, stream: true }),
|
||||
signal: signal ?? null,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => response.statusText);
|
||||
throw new Error(`Stream request failed: ${errorText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is not readable");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
|
||||
let readerDone = false;
|
||||
while (!readerDone) {
|
||||
const { done, value } = await reader.read();
|
||||
readerDone = done;
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// SSE messages are separated by double newlines
|
||||
const parts = buffer.split("\n\n");
|
||||
// Keep the last (potentially incomplete) part
|
||||
buffer = parts.pop() ?? "";
|
||||
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
for (const line of trimmed.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
|
||||
const data = line.slice("data: ".length).trim();
|
||||
|
||||
if (data === "[DONE]") {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data) as SseChunk;
|
||||
|
||||
if (parsed.error) {
|
||||
throw new Error(parsed.error);
|
||||
}
|
||||
|
||||
if (parsed.message?.content) {
|
||||
onChunk(parsed.message.content);
|
||||
}
|
||||
} catch (parseErr) {
|
||||
if (parseErr instanceof SyntaxError) {
|
||||
continue;
|
||||
}
|
||||
throw parseErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Natural end of stream without [DONE]
|
||||
onComplete();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
onError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -202,9 +202,13 @@ export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions
|
||||
...baseHeaders,
|
||||
};
|
||||
|
||||
// Add workspace ID header if provided (recommended over query string)
|
||||
if (workspaceId) {
|
||||
headers["X-Workspace-Id"] = workspaceId;
|
||||
// Add workspace ID header — use explicit value, or auto-detect from localStorage
|
||||
const resolvedWorkspaceId =
|
||||
workspaceId ??
|
||||
(typeof window !== "undefined" ? localStorage.getItem("mosaic-workspace-id") : null) ??
|
||||
undefined;
|
||||
if (resolvedWorkspaceId) {
|
||||
headers["X-Workspace-Id"] = resolvedWorkspaceId;
|
||||
}
|
||||
|
||||
// Add CSRF token for state-changing requests (POST, PUT, PATCH, DELETE)
|
||||
@@ -246,6 +250,11 @@ export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
// 204 No Content responses have no body — return undefined cast to T
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return await (response.json() as Promise<T>);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
|
||||
@@ -44,7 +44,10 @@ export interface DomainFilters {
|
||||
/**
|
||||
* Fetch all domains
|
||||
*/
|
||||
export async function fetchDomains(filters?: DomainFilters): Promise<ApiResponse<Domain[]>> {
|
||||
export async function fetchDomains(
|
||||
filters?: DomainFilters,
|
||||
workspaceId?: string
|
||||
): Promise<ApiResponse<Domain[]>> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters?.search) {
|
||||
@@ -60,7 +63,7 @@ export async function fetchDomains(filters?: DomainFilters): Promise<ApiResponse
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/api/domains?${queryString}` : "/api/domains";
|
||||
|
||||
return apiGet<ApiResponse<Domain[]>>(endpoint);
|
||||
return apiGet<ApiResponse<Domain[]>>(endpoint, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,20 +76,27 @@ export async function fetchDomain(id: string): Promise<DomainWithCounts> {
|
||||
/**
|
||||
* Create a new domain
|
||||
*/
|
||||
export async function createDomain(data: CreateDomainDto): Promise<Domain> {
|
||||
return apiPost<Domain>("/api/domains", data);
|
||||
export async function createDomain(data: CreateDomainDto, workspaceId?: string): Promise<Domain> {
|
||||
return apiPost<Domain>("/api/domains", data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a domain
|
||||
*/
|
||||
export async function updateDomain(id: string, data: UpdateDomainDto): Promise<Domain> {
|
||||
return apiPatch<Domain>(`/api/domains/${id}`, data);
|
||||
export async function updateDomain(
|
||||
id: string,
|
||||
data: UpdateDomainDto,
|
||||
workspaceId?: string
|
||||
): Promise<Domain> {
|
||||
return apiPatch<Domain>(`/api/domains/${id}`, data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a domain
|
||||
*/
|
||||
export async function deleteDomain(id: string): Promise<Record<string, never>> {
|
||||
return apiDelete<Record<string, never>>(`/api/domains/${id}`);
|
||||
export async function deleteDomain(
|
||||
id: string,
|
||||
workspaceId?: string
|
||||
): Promise<Record<string, never>> {
|
||||
return apiDelete<Record<string, never>>(`/api/domains/${id}`, workspaceId);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user