diff --git a/.env.example b/.env.example index bb83a4f..f545980 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,13 @@ SESSION_SECRET=your_session_secret SESSION_SECURE=false SESSION_SAME_SITE=lax +# App URLs +APP_URL=http://localhost:3000 +FRONTEND_URL=http://localhost:2211 + +# Integration encryption +INTEGRATION_ENCRYPTION_KEY=your_64_char_hex_key + # CORS CORS_ENABLED=true CORS_ORIGIN=http://localhost:2211,* @@ -66,3 +73,26 @@ LANGSMITH_PROJECT="Launchline" #Customer-ui NEXT_PUBLIC_API_URL=http://localhost:2211 NEXT_PUBLIC_CORE_API_URL=http://localhost:3000 + +# Linear Integration +LINEAR_CLIENT_ID= +LINEAR_CLIENT_SECRET= +LINEAR_WEBHOOK_SECRET= + +# Slack Integration +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= +SLACK_SIGNING_SECRET= + +# Google Integration +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# GitHub Integration +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret +GITHUB_WEBHOOK_SECRET=your_github_webhook_secret +# GitHub App (preferred) +GITHUB_APP_ID=your_github_app_id +GITHUB_APP_SLUG=your_github_app_slug +GITHUB_APP_PRIVATE_KEY=your_github_app_private_key diff --git a/README.md b/README.md index 4fdfc33..16955b6 100644 --- a/README.md +++ b/README.md @@ -2,294 +2,77 @@ **Transparency without micromanagement. Impact without fake KPIs.** -Launchline helps teams see what's *actually* happening - not what fits in spreadsheets. +Launchline is an AI execution inbox for product teams. It turns signals from Linear, GitHub, and Slack into clear, actionable context so PMs can see what is actually happening without surveillance or vanity metrics. ---- +## Why Launchline -## Why Launchline? +Most teams do not lack effort. They lack shared context. -Most teams waste resources not because people aren't working, but because **context is invisible**: +- Dashboards show tickets closed, but miss blockers and invisible work. +- Metrics reward activity, not impact. +- One-size KPIs punish deep work and collaboration. -- A developer who "closes fewer tickets" might be preventing production fires -- Someone who writes less code might be unlocking others through great design -- A PM who doesn't "ship features" might be keeping the team aligned +Launchline focuses on reality: what is stuck, what is drifting, and who is unblocking the team. -### The problem with traditional tracking +## What you can demo today -**Micromanagement metrics** (hours, commits, messages) → fake productivity -**Surface-level dashboards** (tickets closed) → miss the real story -**One-size-fits-all KPIs** → punish different working styles +- **Execution Inbox** with auto-generated items after Linear connect +- **Linea agent** that explains blockers, drift, and hidden impact +- **Workspace-aware prompts** so each workspace can tune Linea +- **Human-in-the-loop actions** (Linear updates, Slack messages, GitHub issues) +- **Slack interaction** so teams can talk to Linea outside the web UI -Some people thrive on daily increments. Others need deep focus and moments of inspiration. Traditional tools force everyone into the same mold - and punish those who don't fit. +## Demo flow (5 minutes) -### What Launchline does differently +1. **Connect Linear** (Settings -> Connect Linear) +2. **Show inbox items** (blockers, stalled work, unassigned priorities) +3. **Ask Linea for sprint status** (suggestion prompt) +4. **Show hidden impact** ("What has Sarah been doing?") +5. **Generate a project update** (stakeholder-ready summary) +6. **Optional: send a Slack update** from Linea -Launchline is an **AI-augmented execution platform** that: +## How it works -- **Surfaces hidden impact** - code reviews, mentoring, firefighting, unblocking others -- **Respects context** - understands *why* work is slow, not just *that* it is -- **Embraces different styles** - recognizes value that doesn't show up in Jira -- **Increases transparency** - gives managers clarity without surveillance -- **Keeps it fair** - respects people who do their job, highlights those who go beyond - -We collect signals across your tools (Linear, GitHub, Slack), turn them into insights, and help you understand: - -- Where resources are actually going -- What blockers exist *before* they derail your roadmap -- How each team member's strengths can be better utilized -- Which small actions have the biggest impact on your product - ---- - -## The vision - -### Today: Execution inbox - -An AI-powered PM inbox that surfaces blockers, priority drift, and decisions across tools. - -### Next: Intelligent resource allocation - -- **Predict blockers** before they happen (ML models + platform signals) -- **Recommend work distribution** based on team strengths and historical patterns -- **Show broader context** - what founders miss while focused on immediate priorities - -### Eventually: Impact transparency for everyone - -- **For developers**: See how your work shapes the product, even when it's not "shipping features" -- **For teams**: Highlight underdeveloped areas, frequent pain points, customer impact zones -- **For organizations**: Understand how small actions compound into outcomes - -We believe teams should organize around people's strengths - not force people into arbitrary processes. - ---- - -## Features - -- **PM Inbox** - - One place for blockers, priority drift, and project update opportunities -- **Generative summaries** - - Human-readable explanations of context across Linear, GitHub, Slack -- **Suggested actions** - - Structured, user-approved actions (update Linear, comment on GitHub, notify via Slack) -- **Conversational threads** - - Chat-style interface for each Inbox item, powered by AI agents -- **Tool integrations** - - Current: Linear, GitHub, Slack - - Planned: Notion, Jira, Figma, feedback tools - ---- - -## The bigger picture - -Product work is becoming too complex for humans alone. - -Modern products are built across dozens of tools, teams, and decisions. PMs are expected to keep everything aligned - but the system itself doesn't learn. - -Launchline is building the missing layer: a **product copilot** that understands context, history, and tradeoffs - not just tickets. - -### Execution → Memory → Copilot - -**Execution inbox**: Captures blockers, risks, and decisions across tools -**Product memory**: Understands how your team, product, and users behave over time -**Product copilot**: Helps steer priorities and tradeoffs - grounded in reality - -We start where the pain is highest: **execution**. Inbox → decisions → unblock → ship. - ---- - -## Architecture (high level) - -Signals flow through the system as follows: - -```text -Signals (webhooks / SDK events) -↓ -PostgreSQL (structured event store) -↓ -LangGraph (memory + semantic relationships) -↓ -Rule engine (blocker / drift / staleness detection) -↓ -Inbox items -↓ -Assistant UI + DeepAgent (chat + reasoning) -↓ -Tool calls (user‑approved execution) +``` +Signals (webhooks, SDK events) + -> PostgreSQL (event store) + -> LangGraph (memory + relationships) + -> Rules + LLM analysis + -> Inbox items + -> Linea agent + tool calls ``` Key components: -- **Backend**: Nest.js / Express (TypeScript) REST APIs and background workers. -- **Frontend**: Next.js + React + Shadcn UI. -- **Storage**: PostgreSQL for structured data, Redis for caching/rate limits. -- **Queueing**: RabbitMQ (optional) for batched async jobs. -- **AI layer**: LangGraph + LangChain DeepAgent for reasoning and summaries. - ---- - -## Open‑core model & licensing - -Launchline is **open‑core** and **self‑hostable**. - -- **Core application** (server, UI, agents, prompts, graphs, workflows) - - Licensed under **Business Source License 1\.1 (BSL 1\.1)** with a *no competing hosted service* restriction. - - See `LICENSE`. - -- **SDKs and client libraries** (TypeScript / JavaScript) - - Licensed under **MIT** for maximum adoption. - - See the `LICENSE` file inside each SDK package (for example `packages/sdk`). - -You can: +- **Backend**: NestJS (REST + GraphQL), background workers +- **Frontend**: Next.js + Assistant UI +- **Storage**: Postgres + Redis +- **AI layer**: LangGraph + LangChain + DeepAgents -- Audit and self‑host the full platform. -- Use the SDKs in commercial and proprietary applications. -- Not offer Launchline (or a substantially similar service) as a competing hosted service. +## Local development ---- - -## Environment configuration - -Before running the application (locally or with Docker), copy `.env.example` to `.env` in the repo root and fill in the required environment variables. -**Do not commit real secrets or credentials.** -See `.env` for the full list of required variables (database, cache, API keys, etc). - ---- - -## Self‑hosting with `docker‑compose` - -### Prerequisites - -- Docker -- Docker Compose -- A `.env` file in the repo root (see above). - -### Start the stack - -From the repository root: +1. Copy `.env.example` to `.env` and fill required values. +2. Start dependencies: ```bash docker-compose up -d ``` -This will start **only the development dependencies/services** needed for local development: - -- PostgreSQL (and Redis / RabbitMQ if configured) - -> The full local production-ready stack (including backend and frontend containers) will be documented later. - -### Access - -- UI: `http://localhost:3000` (or the port from `LAUNCHLINE_PORT`) -- API: `http://localhost:3000/api` (or your configured base path) - -You can now connect Linear, GitHub, and Slack, then start seeing Inbox items for blockers, drift, and updates. - ---- - -## Using the TypeScript / JavaScript SDK - -The SDK is MIT‑licensed and provides a thin client for calling the Launchline API. - -Install from npm: - -```bash -npm install @launchline/sdk -# or -yarn add @launchline/sdk -# or -pnpm add @launchline/sdk -``` - -Basic usage: - -```ts -import { LaunchlineClient } from '@launchline/sdk'; - -const client = new LaunchlineClient({ - baseUrl: 'http://localhost:3000', - apiKey: process.env.LAUNCHLINE_API_KEY, -}); - -async function main() { - const result = await client.agents.run({ - agentId: 'my-agent-id', - input: { message: 'Summarize current blockers' }, - }); - - console.log(result); -} - -main().catch(console.error); -``` - -See the SDK package `README` for full API documentation and examples. - ---- - -## Local development (without Docker) - -1. Install dependencies: +3. Start the stack (from repo root): ```bash -npm install -# or -yarn install -# or -pnpm install -``` - -2. Ensure your local services (PostgreSQL, Redis, RabbitMQ) are running. - You can use `docker-compose up -d` to start these dependencies. - -3. Create and configure your `.env` file as described above. - -4. Run database migrations: - -```bash -pnpm nx migrate-run core -``` - -5. Start dev servers: - -```bash -# Backend API (with HMR) pnpm nx serve-hmr core - -# Frontend (customer UI) -pnpm nx start customer-ui -p 2201 +pnpm nx dev customer-ui ``` ---- - -## Contributing - -Issues and pull requests are welcome. The community can shape the roadmap - thoughtful contributions are encouraged. - -For contribution and collaboration guidelines (licensing, code review, PR expectations, triage), see `./CONTRIBUTING.md`. - -By contributing, you agree that: - -- Changes to the core application are licensed under **BSL 1.1**. -- Changes to SDKs and libraries follow the license of that package (usually **MIT**). - -We believe PM tools should be auditable, extensible, and community-owned. - ---- +UI: `http://localhost:4200` +API: `http://localhost:3000/graphql` ## License -- Core application, prompts, graphs, and workflows: **Business Source License 1\.1 (BSL 1\.1)** with a *no competing hosted service* restriction. See `LICENSE`. -- SDKs and client libraries: **MIT**. See each package\-specific `LICENSE`. - ---- - -## Third-Party Services - -### Analytics & Notifications - -This application currently uses [PostHog](https://posthog.com) for: -- **Analytics**: User behavior tracking and product analytics -- **OTP Email Delivery**: One-time password emails for authentication +Launchline is open-core and self-hostable. -**Note**: We plan to add support for custom SNS (Simple Notification Service) configurations in the future, allowing you to use your own email delivery service. For now, PostHog handles both analytics and transactional emails. +- **Core platform**: Business Source License 1.1 (no competing hosted service) +- **SDKs and client libraries**: MIT +See `LICENSE.txt` for details. diff --git a/apps/core/migrations/0001_oval_sentinel.sql b/apps/core/migrations/0001_oval_sentinel.sql new file mode 100644 index 0000000..92968af --- /dev/null +++ b/apps/core/migrations/0001_oval_sentinel.sql @@ -0,0 +1,31 @@ +CREATE TYPE "public"."IntegrationStatus" AS ENUM('pending', 'active', 'error', 'expired', 'revoked');--> statement-breakpoint +CREATE TYPE "public"."IntegrationType" AS ENUM('linear', 'slack', 'github', 'jira', 'notion');--> statement-breakpoint +CREATE TABLE "Integration" ( + "id" text PRIMARY KEY NOT NULL, + "createdAt" timestamp (3) DEFAULT now() NOT NULL, + "updatedAt" timestamp (3) NOT NULL, + "workspaceId" text NOT NULL, + "type" "IntegrationType" NOT NULL, + "status" "IntegrationStatus" DEFAULT 'pending' NOT NULL, + "name" text, + "description" text, + "externalAccountId" text, + "externalAccountName" text, + "externalOrganizationId" text, + "externalOrganizationName" text, + "scopes" text, + "webhookId" text, + "webhookUrl" text, + "webhookSecret" text, + "accessToken" text, + "refreshToken" text, + "tokenType" text, + "tokenExpiresAt" timestamp (3), + "lastSyncAt" timestamp (3) +); +--> statement-breakpoint +ALTER TABLE "User" ADD COLUMN "primaryWorkspaceId" text NOT NULL;--> statement-breakpoint +ALTER TABLE "Integration" ADD CONSTRAINT "Integration_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."Workspace"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "Integration_workspaceId_idx" ON "Integration" USING btree ("workspaceId");--> statement-breakpoint +CREATE INDEX "Integration_type_idx" ON "Integration" USING btree ("type");--> statement-breakpoint +CREATE INDEX "Integration_status_idx" ON "Integration" USING btree ("status"); \ No newline at end of file diff --git a/apps/core/migrations/0002_small_squadron_sinister.sql b/apps/core/migrations/0002_small_squadron_sinister.sql new file mode 100644 index 0000000..589f385 --- /dev/null +++ b/apps/core/migrations/0002_small_squadron_sinister.sql @@ -0,0 +1,7 @@ +ALTER TYPE "public"."IntegrationType" ADD VALUE 'google';--> statement-breakpoint +ALTER TABLE "Integration" DROP CONSTRAINT "Integration_workspaceId_fkey"; +--> statement-breakpoint +ALTER TABLE "Integration" DROP COLUMN "webhookId";--> statement-breakpoint +ALTER TABLE "Integration" DROP COLUMN "webhookUrl";--> statement-breakpoint +ALTER TABLE "Integration" DROP COLUMN "webhookSecret";--> statement-breakpoint +ALTER TABLE "Integration" DROP COLUMN "tokenType"; \ No newline at end of file diff --git a/apps/core/migrations/meta/0001_snapshot.json b/apps/core/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..ac76091 --- /dev/null +++ b/apps/core/migrations/meta/0001_snapshot.json @@ -0,0 +1,971 @@ +{ + "id": "0d2021c3-8995-40fe-a2af-646cab8e1f22", + "prevId": "533245d6-bca3-4fbd-9e08-412e0740fa3f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Integration": { + "name": "Integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "workspaceId": { + "name": "workspaceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "IntegrationType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "IntegrationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalAccountId": { + "name": "externalAccountId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalAccountName": { + "name": "externalAccountName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalOrganizationId": { + "name": "externalOrganizationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalOrganizationName": { + "name": "externalOrganizationName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhookId": { + "name": "webhookId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhookUrl": { + "name": "webhookUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhookSecret": { + "name": "webhookSecret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenType": { + "name": "tokenType", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenExpiresAt": { + "name": "tokenExpiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "lastSyncAt": { + "name": "lastSyncAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "Integration_workspaceId_idx": { + "name": "Integration_workspaceId_idx", + "columns": [ + { + "expression": "workspaceId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Integration_type_idx": { + "name": "Integration_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Integration_status_idx": { + "name": "Integration_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Integration_workspaceId_fkey": { + "name": "Integration_workspaceId_fkey", + "tableFrom": "Integration", + "tableTo": "Workspace", + "columnsFrom": [ + "workspaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Notification": { + "name": "Notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entityId": { + "name": "entityId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entityType": { + "name": "entityType", + "type": "NotificationEntityType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "NotificationPriority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEDIUM'" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "readAt": { + "name": "readAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "actionUrl": { + "name": "actionUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actionLabel": { + "name": "actionLabel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "Notification_userId_idx": { + "name": "Notification_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Notification_entityType_idx": { + "name": "Notification_entityType_idx", + "columns": [ + { + "expression": "entityType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Notification_entityId_idx": { + "name": "Notification_entityId_idx", + "columns": [ + { + "expression": "entityId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Notification_readAt_idx": { + "name": "Notification_readAt_idx", + "columns": [ + { + "expression": "readAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Notification_createdAt_idx": { + "name": "Notification_createdAt_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Otp": { + "name": "Otp", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "isVerified": { + "name": "isVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attemptCount": { + "name": "attemptCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "Otp_identifier_isVerified_expiresAt_idx": { + "name": "Otp_identifier_isVerified_expiresAt_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "isVerified", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Otp_expiresAt_idx": { + "name": "Otp_expiresAt_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Otp_userId_fkey": { + "name": "Otp_userId_fkey", + "tableFrom": "Otp", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.User": { + "name": "User", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isEmailVerified": { + "name": "isEmailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isOnboardingComplete": { + "name": "isOnboardingComplete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "role": { + "name": "role", + "type": "UserRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'WORKSPACE_ADMIN'" + }, + "fullName": { + "name": "fullName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primaryWorkspaceId": { + "name": "primaryWorkspaceId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "User_email_idx": { + "name": "User_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Workspace": { + "name": "Workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deactivatedAt": { + "name": "deactivatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.WorkspaceInvite": { + "name": "WorkspaceInvite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "createdByUserId": { + "name": "createdByUserId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspaceMembershipId": { + "name": "workspaceMembershipId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "disabledAt": { + "name": "disabledAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "WorkspaceInvite_token_key": { + "name": "WorkspaceInvite_token_key", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "WorkspaceInvite_expiresAt_idx": { + "name": "WorkspaceInvite_expiresAt_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "WorkspaceInvite_consumedAt_idx": { + "name": "WorkspaceInvite_consumedAt_idx", + "columns": [ + { + "expression": "consumedAt", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "WorkspaceInvite_workspaceMembershipId_fkey": { + "name": "WorkspaceInvite_workspaceMembershipId_fkey", + "tableFrom": "WorkspaceInvite", + "tableTo": "WorkspaceMembership", + "columnsFrom": [ + "workspaceMembershipId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.WorkspaceMembership": { + "name": "WorkspaceMembership", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "workspaceId": { + "name": "workspaceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "WorkspaceMembershipRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "status": { + "name": "status", + "type": "WorkspaceMembershipStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'INVITED'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fullName": { + "name": "fullName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deactivatedAt": { + "name": "deactivatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "WorkspaceMembership_workspace_user_unique": { + "name": "WorkspaceMembership_workspace_user_unique", + "columns": [ + { + "expression": "workspaceId", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "WorkspaceMembership_workspaceId_fkey": { + "name": "WorkspaceMembership_workspaceId_fkey", + "tableFrom": "WorkspaceMembership", + "tableTo": "Workspace", + "columnsFrom": [ + "workspaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.IntegrationStatus": { + "name": "IntegrationStatus", + "schema": "public", + "values": [ + "pending", + "active", + "error", + "expired", + "revoked" + ] + }, + "public.IntegrationType": { + "name": "IntegrationType", + "schema": "public", + "values": [ + "linear", + "slack", + "github", + "jira", + "notion" + ] + }, + "public.NotificationEntityType": { + "name": "NotificationEntityType", + "schema": "public", + "values": [ + "USER", + "SYSTEM" + ] + }, + "public.NotificationPriority": { + "name": "NotificationPriority", + "schema": "public", + "values": [ + "LOW", + "MEDIUM", + "HIGH", + "URGENT" + ] + }, + "public.UserRole": { + "name": "UserRole", + "schema": "public", + "values": [ + "ADMIN", + "WORKSPACE_MEMBER", + "WORKSPACE_ADMIN" + ] + }, + "public.WorkspaceMembershipRole": { + "name": "WorkspaceMembershipRole", + "schema": "public", + "values": [ + "MEMBER", + "ADMIN" + ] + }, + "public.WorkspaceMembershipStatus": { + "name": "WorkspaceMembershipStatus", + "schema": "public", + "values": [ + "INVITED", + "ACTIVE", + "INACTIVE", + "EXPIRED", + "REVOKED" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/core/migrations/meta/0002_snapshot.json b/apps/core/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..fc5c42a --- /dev/null +++ b/apps/core/migrations/meta/0002_snapshot.json @@ -0,0 +1,934 @@ +{ + "id": "7697851c-b168-4890-b9de-6686302384ac", + "prevId": "0d2021c3-8995-40fe-a2af-646cab8e1f22", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Integration": { + "name": "Integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "workspaceId": { + "name": "workspaceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "IntegrationType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "IntegrationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalAccountId": { + "name": "externalAccountId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalAccountName": { + "name": "externalAccountName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalOrganizationId": { + "name": "externalOrganizationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalOrganizationName": { + "name": "externalOrganizationName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenExpiresAt": { + "name": "tokenExpiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "lastSyncAt": { + "name": "lastSyncAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "Integration_workspaceId_idx": { + "name": "Integration_workspaceId_idx", + "columns": [ + { + "expression": "workspaceId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Integration_type_idx": { + "name": "Integration_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Integration_status_idx": { + "name": "Integration_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Notification": { + "name": "Notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entityId": { + "name": "entityId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entityType": { + "name": "entityType", + "type": "NotificationEntityType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "NotificationPriority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEDIUM'" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "readAt": { + "name": "readAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "actionUrl": { + "name": "actionUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actionLabel": { + "name": "actionLabel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "Notification_userId_idx": { + "name": "Notification_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Notification_entityType_idx": { + "name": "Notification_entityType_idx", + "columns": [ + { + "expression": "entityType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Notification_entityId_idx": { + "name": "Notification_entityId_idx", + "columns": [ + { + "expression": "entityId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Notification_readAt_idx": { + "name": "Notification_readAt_idx", + "columns": [ + { + "expression": "readAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Notification_createdAt_idx": { + "name": "Notification_createdAt_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Otp": { + "name": "Otp", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "isVerified": { + "name": "isVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attemptCount": { + "name": "attemptCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "Otp_identifier_isVerified_expiresAt_idx": { + "name": "Otp_identifier_isVerified_expiresAt_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "isVerified", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Otp_expiresAt_idx": { + "name": "Otp_expiresAt_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Otp_userId_fkey": { + "name": "Otp_userId_fkey", + "tableFrom": "Otp", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.User": { + "name": "User", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isEmailVerified": { + "name": "isEmailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isOnboardingComplete": { + "name": "isOnboardingComplete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "role": { + "name": "role", + "type": "UserRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'WORKSPACE_ADMIN'" + }, + "fullName": { + "name": "fullName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primaryWorkspaceId": { + "name": "primaryWorkspaceId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "User_email_idx": { + "name": "User_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Workspace": { + "name": "Workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deactivatedAt": { + "name": "deactivatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.WorkspaceInvite": { + "name": "WorkspaceInvite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "createdByUserId": { + "name": "createdByUserId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspaceMembershipId": { + "name": "workspaceMembershipId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "disabledAt": { + "name": "disabledAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "WorkspaceInvite_token_key": { + "name": "WorkspaceInvite_token_key", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "WorkspaceInvite_expiresAt_idx": { + "name": "WorkspaceInvite_expiresAt_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "WorkspaceInvite_consumedAt_idx": { + "name": "WorkspaceInvite_consumedAt_idx", + "columns": [ + { + "expression": "consumedAt", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "WorkspaceInvite_workspaceMembershipId_fkey": { + "name": "WorkspaceInvite_workspaceMembershipId_fkey", + "tableFrom": "WorkspaceInvite", + "tableTo": "WorkspaceMembership", + "columnsFrom": [ + "workspaceMembershipId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.WorkspaceMembership": { + "name": "WorkspaceMembership", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "workspaceId": { + "name": "workspaceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "WorkspaceMembershipRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "status": { + "name": "status", + "type": "WorkspaceMembershipStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'INVITED'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fullName": { + "name": "fullName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deactivatedAt": { + "name": "deactivatedAt", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "WorkspaceMembership_workspace_user_unique": { + "name": "WorkspaceMembership_workspace_user_unique", + "columns": [ + { + "expression": "workspaceId", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "WorkspaceMembership_workspaceId_fkey": { + "name": "WorkspaceMembership_workspaceId_fkey", + "tableFrom": "WorkspaceMembership", + "tableTo": "Workspace", + "columnsFrom": [ + "workspaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.IntegrationStatus": { + "name": "IntegrationStatus", + "schema": "public", + "values": [ + "pending", + "active", + "error", + "expired", + "revoked" + ] + }, + "public.IntegrationType": { + "name": "IntegrationType", + "schema": "public", + "values": [ + "linear", + "slack", + "github", + "jira", + "notion", + "google" + ] + }, + "public.NotificationEntityType": { + "name": "NotificationEntityType", + "schema": "public", + "values": [ + "USER", + "SYSTEM" + ] + }, + "public.NotificationPriority": { + "name": "NotificationPriority", + "schema": "public", + "values": [ + "LOW", + "MEDIUM", + "HIGH", + "URGENT" + ] + }, + "public.UserRole": { + "name": "UserRole", + "schema": "public", + "values": [ + "ADMIN", + "WORKSPACE_MEMBER", + "WORKSPACE_ADMIN" + ] + }, + "public.WorkspaceMembershipRole": { + "name": "WorkspaceMembershipRole", + "schema": "public", + "values": [ + "MEMBER", + "ADMIN" + ] + }, + "public.WorkspaceMembershipStatus": { + "name": "WorkspaceMembershipStatus", + "schema": "public", + "values": [ + "INVITED", + "ACTIVE", + "INACTIVE", + "EXPIRED", + "REVOKED" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/core/migrations/meta/_journal.json b/apps/core/migrations/meta/_journal.json index eb931a8..7f09ddd 100644 --- a/apps/core/migrations/meta/_journal.json +++ b/apps/core/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1766353565143, "tag": "0000_shocking_reptil", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1769697864446, + "tag": "0001_oval_sentinel", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1769974559684, + "tag": "0002_small_squadron_sinister", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/core/project.json b/apps/core/project.json index aaa49f7..75dca94 100644 --- a/apps/core/project.json +++ b/apps/core/project.json @@ -136,6 +136,12 @@ "cwd": "apps/core", "command": "drizzle-kit studio --config=drizzle.config.ts" } + }, + "demo-seed": { + "executor": "nx:run-commands", + "options": { + "command": "node -r ts-node/register -r tsconfig-paths/register apps/core/src/demo-seed.ts" + } } } } diff --git a/apps/core/src/app/app.module.ts b/apps/core/src/app/app.module.ts index cbcc93e..9fbb0b6 100644 --- a/apps/core/src/app/app.module.ts +++ b/apps/core/src/app/app.module.ts @@ -22,6 +22,7 @@ import { } from '@launchline/core-common'; import { AuthModule } from '@launchline/core-auth'; import { WorkspaceModule } from '@launchline/core-workspace'; +import { IntegrationModule } from '@launchline/core-integration'; import { LineaModule } from '@launchline/core-linea'; import { config, validate } from './configuration'; import { @@ -86,6 +87,14 @@ import { (req as Record | undefined)?.user || (extra as { request: Record } | undefined) ?.request?.user; + const currentWorkspaceId = + (req as Record> | undefined) + ?.session.currentWorkspaceId || + ( + extra as + | { request: Record> } + | undefined + )?.request?.session.currentWorkspaceId; const marketingId = res ? getOrCreateMarketingSessionId( res as Response, @@ -99,6 +108,7 @@ import { user, sessionId, marketingId, + currentWorkspaceId, }; }, plugins: [ @@ -157,6 +167,7 @@ import { CommonModule, AuthModule, WorkspaceModule, + IntegrationModule, LineaModule, ], }) diff --git a/apps/core/src/app/configuration.ts b/apps/core/src/app/configuration.ts index 2190911..1dd63ed 100644 --- a/apps/core/src/app/configuration.ts +++ b/apps/core/src/app/configuration.ts @@ -14,6 +14,8 @@ const configSchema = z.object({ enableApolloLandingPage: z.boolean(), gracefulShutdownTimeoutMs: z.number(), trustProxy: z.boolean(), + appUrl: z.string(), + frontendUrl: z.string(), cors: z.object({ enabled: z.boolean(), origin: z.array(z.string()), @@ -29,6 +31,39 @@ const configSchema = z.object({ marketing: z.object({ cookieName: z.string(), }), + integrations: z.object({ + encryptionKey: z.string().length(64), // 32 bytes hex-encoded + linear: z + .object({ + clientId: z.string(), + clientSecret: z.string(), + webhookSecret: z.optional(z.string()), + }) + .optional(), + slack: z + .object({ + clientId: z.string(), + clientSecret: z.string(), + signingSecret: z.string(), + }) + .optional(), + google: z + .object({ + clientId: z.string(), + clientSecret: z.string(), + }) + .optional(), + github: z + .object({ + clientId: z.optional(z.string()), + clientSecret: z.optional(z.string()), + webhookSecret: z.optional(z.string()), + appId: z.optional(z.string()), + appSlug: z.optional(z.string()), + privateKey: z.optional(z.string()), + }) + .optional(), + }), database: z.object({ user: z.string(), password: z.string(), @@ -88,6 +123,8 @@ export const config = () => ({ 10, ), trustProxy: process.env.TRUST_PROXY === 'true', + appUrl: process.env.APP_URL || 'http://localhost:3000', + frontendUrl: process.env.FRONTEND_URL || 'http://localhost:4200', cors: { enabled: process.env.CORS_ENABLED === 'true', origin: process.env.CORS_ORIGIN?.split(','), @@ -105,6 +142,42 @@ export const config = () => ({ marketing: { cookieName: process.env.MARKETING_COOKIE_NAME || 'core.marketingsid', }, + integrations: { + encryptionKey: process.env.INTEGRATION_ENCRYPTION_KEY, + linear: process.env.LINEAR_CLIENT_ID + ? { + clientId: process.env.LINEAR_CLIENT_ID, + clientSecret: process.env.LINEAR_CLIENT_SECRET, + webhookSecret: process.env.LINEAR_WEBHOOK_SECRET, + } + : undefined, + slack: process.env.SLACK_CLIENT_ID + ? { + clientId: process.env.SLACK_CLIENT_ID, + clientSecret: process.env.SLACK_CLIENT_SECRET, + signingSecret: process.env.SLACK_SIGNING_SECRET || '', + } + : undefined, + google: process.env.GOOGLE_CLIENT_ID + ? { + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', + } + : undefined, + github: + process.env.GITHUB_CLIENT_ID || + process.env.GITHUB_APP_ID || + process.env.GITHUB_APP_SLUG + ? { + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET || '', + webhookSecret: process.env.GITHUB_WEBHOOK_SECRET || '', + appId: process.env.GITHUB_APP_ID, + appSlug: process.env.GITHUB_APP_SLUG, + privateKey: process.env.GITHUB_APP_PRIVATE_KEY, + } + : undefined, + }, database: { user: process.env.DB_USERNAME, password: process.env.DB_PASS, diff --git a/apps/core/src/demo-seed.ts b/apps/core/src/demo-seed.ts new file mode 100644 index 0000000..a8ae4f8 --- /dev/null +++ b/apps/core/src/demo-seed.ts @@ -0,0 +1,157 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { randomUUID } from 'crypto'; +import { AppModule } from './app/app.module'; +import { AssistantService } from '@launchline/core-linea'; +import { MemoryService } from '@launchline/core-linea'; +import { LineaJobsService } from '@launchline/core-linea'; +import { AgentPromptService } from '@launchline/core-linea'; +import type { InboxItemCandidate } from '@launchline/core-linea'; + +async function seed() { + const workspaceId = process.env.DEMO_WORKSPACE_ID; + const userId = process.env.DEMO_USER_ID; + + if (!workspaceId || !userId) { + throw new Error( + 'DEMO_WORKSPACE_ID and DEMO_USER_ID must be set to seed demo data.', + ); + } + + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + const assistantService = app.get(AssistantService); + const memoryService = app.get(MemoryService); + const jobsService = app.get(LineaJobsService); + const promptService = app.get(AgentPromptService); + + const ctx = { + workspaceId, + userId, + correlationId: `demo-seed-${Date.now()}`, + }; + + await promptService.upsertWorkspacePrompt( + workspaceId, + `You are Linea, the always-on product copilot. +- Favor crisp, actionable updates. +- Keep status updates to 5 bullets max. +- Ask before sending external messages.`, + userId, + ); + + await memoryService.saveMemory(ctx, { + namespace: 'workspace', + category: 'skill', + summary: 'Launchline demo flow', + content: + 'When presenting the demo, highlight the inbox triage, team graph, and autopilot jobs.', + importance: 0.7, + confidence: 0.9, + sourceEventIds: [], + relatedEntityIds: [], + relatedMemoryIds: [], + entityRefs: {}, + }); + + await memoryService.saveMemory(ctx, { + namespace: 'team', + category: 'identity', + summary: 'PM owner', + content: JSON.stringify({ id: 'user:dawid', displayName: 'Dawid K.' }), + importance: 0.8, + confidence: 0.9, + sourceEventIds: [], + relatedEntityIds: [], + relatedMemoryIds: [], + entityRefs: { userIds: ['user:dawid'] }, + }); + + await memoryService.saveMemory(ctx, { + namespace: 'team', + category: 'identity', + summary: 'Eng lead', + content: JSON.stringify({ id: 'user:alex', displayName: 'Alex M.' }), + importance: 0.8, + confidence: 0.9, + sourceEventIds: [], + relatedEntityIds: [], + relatedMemoryIds: [], + entityRefs: { userIds: ['user:alex'] }, + }); + + const now = new Date(); + const candidates: InboxItemCandidate[] = [ + { + id: `demo-${randomUUID()}`, + workspaceId, + type: 'blocker', + priority: 'high', + title: 'API spec review blocked by auth changes', + summary: + 'Checkout changes need security sign-off before the new endpoint can be shipped.', + confidence: 0.88, + sourceMemoryIds: [], + suggestedActions: [ + 'Ping security owner for approval', + 'Create a contingency plan for release', + ], + requiresApproval: false, + entityRefs: { ticketIds: ['LAU-42'], userIds: ['user:alex'] }, + createdAt: now, + }, + { + id: `demo-${randomUUID()}`, + workspaceId, + type: 'drift', + priority: 'medium', + title: 'Mobile onboarding scope expanded', + summary: + 'Two extra flows were added mid-sprint. Recommend updating success criteria.', + confidence: 0.74, + sourceMemoryIds: [], + suggestedActions: [ + 'Confirm scope changes with design', + 'Update launch checklist', + ], + requiresApproval: false, + entityRefs: { ticketIds: ['LAU-39'], userIds: ['user:dawid'] }, + createdAt: now, + }, + { + id: `demo-${randomUUID()}`, + workspaceId, + type: 'update', + priority: 'low', + title: 'Linear integration shipped', + summary: + 'Webhook ingestion is live. Next: add auto-triage for priority issues.', + confidence: 0.62, + sourceMemoryIds: [], + suggestedActions: ['Draft release note', 'Queue follow-up ticket'], + requiresApproval: false, + entityRefs: { ticketIds: ['LAU-34'] }, + createdAt: now, + }, + ]; + + for (const candidate of candidates) { + await assistantService.createInboxThread( + workspaceId, + userId, + candidate, + ); + } + + await jobsService.ensureHeartbeat(workspaceId, userId); + + await app.close(); +} + +seed().catch((error) => { + // eslint-disable-next-line no-console + console.error('[demo-seed] Failed to seed demo data:', error); + process.exitCode = 1; +}); diff --git a/apps/customer-ui/next.config.js b/apps/customer-ui/next.config.js index 330e326..5e05156 100644 --- a/apps/customer-ui/next.config.js +++ b/apps/customer-ui/next.config.js @@ -53,6 +53,10 @@ const nextConfig = { source: '/assistant/:path*', destination: `${backendUrl}/assistant/:path*`, }, + { + source: '/integrations/:path*', + destination: `${backendUrl}/integrations/:path*`, + }, ]; }, }; diff --git a/apps/customer-ui/public/linear.png b/apps/customer-ui/public/linear.png new file mode 100644 index 0000000..d534500 Binary files /dev/null and b/apps/customer-ui/public/linear.png differ diff --git a/apps/customer-ui/src/app/inbox/inbox-client.tsx b/apps/customer-ui/src/app/inbox/inbox-client.tsx new file mode 100644 index 0000000..a14535a --- /dev/null +++ b/apps/customer-ui/src/app/inbox/inbox-client.tsx @@ -0,0 +1,1515 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useQuery, useSubscription } from '@apollo/client/react'; +import { gql } from '@apollo/client'; +import Link from 'next/link'; +import { LogoIcon } from '@launchline/ui/components/logo'; +import { Button } from '@launchline/ui/components/ui/button'; +import { Badge } from '@launchline/ui/components/ui/badge'; +import { + InboxLineaThread, + InboxItemType, + GeneralLineaThread, +} from '@launchline/ui/components/inbox/inbox-thread'; +import { cn } from '@launchline/ui/lib/utils'; + +// Assistant-UI imports +import { useAssistantApi } from '@assistant-ui/react'; + +// Custom Runtime Provider +import { LaunchlineRuntimeProvider, THREADS_QUERY } from '@launchline/ui'; + +// Integrations query +const INTEGRATIONS_QUERY = gql` + query Integrations { + integrations { + integrations { + id + type + status + } + } + } +`; + +const LINEA_CHANGED_SUBSCRIPTION = gql` + subscription LineaChanged { + lineaChanged { + id + type + changedAt + __typename + } + } +`; + +import { + Inbox, + Settings, + Check, + AlertTriangle, + TrendingDown, + FileText, + Zap, + ArrowUp, + ArrowDown, + Minus, + Command, + Eye, + EyeOff, + Clock, + ArrowUpDown, + X, + ChevronRight, + Sparkles, + MessageSquare, + Users, +} from 'lucide-react'; + +// Priority config +const priorityConfig: Record< + string, + { label: string; color: string; icon: typeof ArrowUp } +> = { + critical: { label: 'Critical', color: 'text-rose-400', icon: ArrowUp }, + high: { label: 'High', color: 'text-amber-400', icon: ArrowUp }, + medium: { label: 'Medium', color: 'text-foreground/60', icon: Minus }, + low: { label: 'Low', color: 'text-foreground/40', icon: ArrowDown }, +}; + +// Type config +const typeConfig: Record< + InboxItemType, + { label: string; icon: typeof AlertTriangle; color: string; bgColor: string } +> = { + blocker: { + label: 'Blocker', + icon: AlertTriangle, + color: 'text-rose-400', + bgColor: 'bg-rose-500/15', + }, + drift: { + label: 'Drift', + icon: TrendingDown, + color: 'text-amber-400', + bgColor: 'bg-amber-500/15', + }, + stalled: { + label: 'Stalled', + icon: Clock, + color: 'text-orange-400', + bgColor: 'bg-orange-500/15', + }, + update: { + label: 'Update', + icon: FileText, + color: 'text-sky-400', + bgColor: 'bg-sky-500/15', + }, + coverage: { + label: 'Coverage', + icon: Zap, + color: 'text-violet-400', + bgColor: 'bg-violet-500/15', + }, + risk: { + label: 'Risk', + icon: AlertTriangle, + color: 'text-red-400', + bgColor: 'bg-red-500/15', + }, + action_required: { + label: 'Action', + icon: Zap, + color: 'text-indigo-400', + bgColor: 'bg-indigo-500/15', + }, +}; + +const resolvedStatusSet = new Set([ + 'actioned', + 'auto-resolved', + 'auto_resolved', + 'closed', + 'dismissed', + 'archived', +]); + +const isResolvedItem = (item: { status?: string; threadStatus?: string }) => + resolvedStatusSet.has(item.status || '') || item.threadStatus === 'archived'; + +// TimeAgo component (hydration safe) +function TimeAgo({ date }: { date: Date }) { + const [timeAgo, setTimeAgo] = useState(''); + + useEffect(() => { + const calculateTimeAgo = () => { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + return `${diffDays}d`; + }; + + setTimeAgo(calculateTimeAgo()); + const interval = setInterval(() => setTimeAgo(calculateTimeAgo()), 60000); + return () => clearInterval(interval); + }, [date]); + + if (!timeAgo) return ...; + return {timeAgo}; +} + +// Command Palette +function CommandPalette({ + open, + onClose, + onCommand, +}: { + open: boolean; + onClose: () => void; + onCommand: (cmd: string) => void; +}) { + const [search, setSearch] = useState(''); + + const commands = [ + { id: 'resolve', label: 'Resolve item', shortcut: 'E' }, + { id: 'assign', label: 'Assign to...', shortcut: 'A' }, + { id: 'priority', label: 'Set priority', shortcut: 'P' }, + { id: 'quick-action', label: 'Quick action (auto)', shortcut: 'Q' }, + { id: 'next', label: 'Next item', shortcut: 'J' }, + { id: 'prev', label: 'Previous item', shortcut: 'K' }, + { id: 'settings', label: 'Open settings', shortcut: ',' }, + ]; + + const filtered = commands.filter((c) => + c.label.toLowerCase().includes(search.toLowerCase()), + ); + + useEffect(() => { + if (open) setSearch(''); + }, [open]); + + if (!open) return null; + + return ( +
+
+
+
+ + setSearch(e.target.value)} + placeholder="Type a command..." + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+
+ {filtered.map((cmd) => ( + + ))} +
+
+
+ ); +} + +function EmptyThreadState({ + hasActiveItems, + hasResolvedItems, +}: { + hasActiveItems: boolean; + hasResolvedItems: boolean; +}) { + const title = hasActiveItems + ? 'Select an inbox item' + : 'All caught up for now'; + const description = hasActiveItems + ? 'Pick an item on the left to review context or resolve it. Linea is also available in Slack when you need a quick answer.' + : 'Linea runs a heartbeat every 30 minutes and will surface blockers, drift, and risks as they appear. You can ask Linea in Slack anytime.'; + const detailLead = hasActiveItems + ? 'Slack-first support' + : 'What happens next'; + + return ( +
+
+
+ {hasActiveItems ? ( + + ) : ( + + )} +
+

{title}

+

{description}

+ +
+

+ {detailLead} +

+
+
+ + DM or @mention Linea for quick answers +
+
+ + Heartbeat scans every 30 minutes +
+
+ + Scheduled digests live in Settings +
+
+
+ +
+ + + +
+ + {hasResolvedItems && !hasActiveItems && ( +

+ Toggle “show resolved” to review past items. +

+ )} +
+
+ ); +} + +// Keyboard shortcuts help +function KeyboardHelp({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + if (!open) return null; + + const shortcuts = [ + { key: 'J / K', desc: 'Navigate items' }, + { key: 'E', desc: 'Resolve selected' }, + { key: 'A', desc: 'Assign to...' }, + { key: 'P', desc: 'Set priority' }, + { key: 'Q', desc: 'Quick action (auto)' }, + { key: '⌘ K', desc: 'Command palette' }, + { key: ',', desc: 'Settings' }, + { key: '?', desc: 'This help' }, + ]; + + return ( +
+
+
+
+

Keyboard shortcuts

+ +
+
+ {shortcuts.map((s) => ( +
+ {s.desc} + + {s.key} + +
+ ))} +
+
+
+ ); +} + +// Main page component wrapped with runtime provider +export default function InboxClient() { + return ( + + + + ); +} + +// Inner component that uses assistant-ui hooks +function InboxPageContent() { + // Get threads from assistant-ui runtime using new API + // Query all thread data including inbox metadata + const { + data: threadsData, + loading, + refetch: refetchThreads, + } = useQuery<{ + threads: { + threads: { + remoteId: string; + status: string; + title?: string; + createdAt?: string; + updatedAt?: string; + isInboxThread?: boolean; + inboxItemType?: string; + inboxPriority?: string; + inboxStatus?: string; + summary?: string; + projectId?: string; + featureId?: string; + }[]; + }; + }>(THREADS_QUERY); + + // Query integrations status + const { + data: integrationsData, + loading: integrationsLoading, + refetch: refetchIntegrations, + } = useQuery<{ + integrations: { + integrations: { + id: string; + type: string; + status: string; + }[]; + }; + }>(INTEGRATIONS_QUERY, { + fetchPolicy: 'network-only', + }); + + // Get runtime to access thread list for archiving + const assistantApi = useAssistantApi(); + + const [selectedItemId, setSelectedItemId] = useState(null); + const [filterType, setFilterType] = useState<'all' | InboxItemType>('all'); + const [showResolved, setShowResolved] = useState(() => { + if (typeof window === 'undefined') { + return false; + } + + return window.localStorage.getItem('linea.showResolved') === 'true'; + }); + const [sortBy, setSortBy] = useState<'priority' | 'time'>('time'); + const [restoringItemId, setRestoringItemId] = useState(null); + const [commandOpen, setCommandOpen] = useState(false); + const [helpOpen, setHelpOpen] = useState(false); + const [justResolvedId, setJustResolvedId] = useState(null); + const [pendingResolveId, setPendingResolveId] = useState(null); + const [optimisticArchivedIds, setOptimisticArchivedIds] = useState( + [], + ); + + useSubscription<{ + lineaChanged: Record; + }>(LINEA_CHANGED_SUBSCRIPTION, { + onData: ({ data }) => { + const event = data.data?.lineaChanged; + if (!event) return; + void refetchThreads(); + void refetchIntegrations(); + }, + }); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem( + 'linea.showResolved', + showResolved ? 'true' : 'false', + ); + }, [showResolved]); + + useEffect(() => { + if (!threadsData?.threads?.threads?.length) { + return; + } + + const archivedIds = new Set( + threadsData.threads.threads + .filter((thread) => (thread.status || '').toLowerCase() === 'archived') + .map((thread) => thread.remoteId), + ); + + setOptimisticArchivedIds((prev) => { + const next = prev.filter((id) => !archivedIds.has(id)); + return next.length === prev.length ? prev : next; + }); + }, [threadsData]); + + const normalizeIntegrationValue = useCallback( + (value?: string) => (value || '').toLowerCase(), + [], + ); + + const generalThread = useMemo(() => { + if (!threadsData?.threads?.threads) { + return null; + } + + const thread = threadsData.threads.threads.find( + (candidate) => + !candidate.isInboxThread && + (candidate.status || '').toLowerCase() !== 'archived', + ); + + if (!thread) { + return null; + } + + return { + id: thread.remoteId, + title: thread.title || 'General chat', + updatedAt: thread.updatedAt || thread.createdAt, + }; + }, [threadsData]); + + const linearIntegration = useMemo(() => { + if (!integrationsData?.integrations?.integrations) { + return null; + } + + return ( + integrationsData.integrations.integrations.find( + (integration) => + normalizeIntegrationValue(integration.type) === 'linear', + ) || null + ); + }, [integrationsData, normalizeIntegrationValue]); + + const linearIntegrationStatus = useMemo(() => { + if (!linearIntegration) { + return null; + } + + return normalizeIntegrationValue(linearIntegration.status); + }, [linearIntegration, normalizeIntegrationValue]); + + // Check if Linear is connected + const hasLinearIntegration = useMemo(() => { + if (integrationsLoading || !integrationsData?.integrations?.integrations) { + return undefined; + } + + if (!linearIntegration) { + return false; + } + + return !['revoked', 'expired'].includes(linearIntegrationStatus || ''); + }, [ + integrationsData, + integrationsLoading, + linearIntegration, + linearIntegrationStatus, + ]); + + const integrationsReady = + !integrationsLoading && typeof hasLinearIntegration === 'boolean'; + + // Map GraphQL threads to inbox items + const items = useMemo(() => { + if (loading || !threadsData?.threads?.threads) { + return []; + } + + const optimisticArchivedSet = new Set(optimisticArchivedIds); + + const mapped = threadsData.threads.threads + .filter((thread) => thread.isInboxThread) + .map((thread) => { + const isOptimisticArchived = optimisticArchivedSet.has(thread.remoteId); + const rawInboxStatus = (thread.inboxStatus || '').toLowerCase(); + const resolvedByStatus = resolvedStatusSet.has(rawInboxStatus); + const normalizedThreadStatus = + isOptimisticArchived || + (thread.status || '').toLowerCase() === 'archived' || + resolvedByStatus + ? 'archived' + : 'regular'; + const fallbackStatus = + normalizedThreadStatus === 'archived' ? 'archived' : 'pending'; + const normalizedInboxStatus = rawInboxStatus || fallbackStatus; + + return { + id: thread.remoteId, + externalId: thread.remoteId, + type: ( + thread.inboxItemType || 'update' + ).toLowerCase() as InboxItemType, + status: normalizedInboxStatus, + threadStatus: normalizedThreadStatus, + priority: thread.inboxPriority?.toLowerCase() || 'medium', + title: thread.title || 'Untitled', + summary: thread.summary || '', + timestamp: thread.createdAt + ? new Date(thread.createdAt) + : thread.updatedAt + ? new Date(thread.updatedAt) + : new Date(0), + projectId: thread.projectId || '', + featureId: thread.featureId || '', + linkedContexts: [], + messages: [], + executionLogs: [], + }; + }); + + return mapped.filter( + (item) => showResolved || item.threadStatus !== 'archived', + ); + }, [threadsData, loading, showResolved, optimisticArchivedIds]); + + // Resolve function using assistant-ui runtime's archive + const resolveItem = useCallback( + async (itemId: string) => { + try { + setOptimisticArchivedIds((prev) => + prev.includes(itemId) ? prev : [...prev, itemId], + ); + await assistantApi.threads().item({ id: itemId }).archive(); + await refetchThreads(); + } catch (error) { + setOptimisticArchivedIds((prev) => prev.filter((id) => id !== itemId)); + console.error('Failed to archive thread:', error); + } + }, + [assistantApi, refetchThreads], + ); + + const restoreItem = useCallback( + async (itemId: string) => { + if (!itemId || restoringItemId) return; + setRestoringItemId(itemId); + try { + await assistantApi.threads().item({ id: itemId }).unarchive(); + setOptimisticArchivedIds((prev) => prev.filter((id) => id !== itemId)); + await refetchThreads(); + } catch (error) { + console.error('Failed to restore thread:', error); + } finally { + setRestoringItemId(null); + } + }, + [assistantApi, refetchThreads, restoringItemId], + ); + + // Filter and sort items + const filteredItems = useMemo(() => { + const result = items.filter((item) => { + const isResolved = isResolvedItem(item); + if (!showResolved && isResolved) return false; + if (filterType !== 'all' && item.type !== filterType) return false; + return true; + }); + + // Sort + result.sort((a, b) => { + if (sortBy === 'priority') { + const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; + const diff = + (priorityOrder[a.priority as keyof typeof priorityOrder] || 2) - + (priorityOrder[b.priority as keyof typeof priorityOrder] || 2); + if (diff !== 0) return diff; + } + return b.timestamp.getTime() - a.timestamp.getTime(); + }); + + return result; + }, [items, filterType, showResolved, sortBy]); + + // Active vs resolved counts + const activeItems = useMemo( + () => filteredItems.filter((item) => !isResolvedItem(item)), + [filteredItems], + ); + const resolvedItems = useMemo( + () => filteredItems.filter((item) => isResolvedItem(item)), + [filteredItems], + ); + const allActiveItems = useMemo( + () => items.filter((item) => !isResolvedItem(item)), + [items], + ); + const allResolvedItems = useMemo( + () => items.filter((item) => isResolvedItem(item)), + [items], + ); + const typeCounts = useMemo(() => { + const counts: Record = { + blocker: 0, + drift: 0, + stalled: 0, + update: 0, + coverage: 0, + risk: 0, + action_required: 0, + }; + + for (const item of allActiveItems) { + counts[item.type] = (counts[item.type] || 0) + 1; + } + + return counts; + }, [allActiveItems]); + const urgentCount = useMemo( + () => + allActiveItems.filter((item) => + ['critical', 'high'].includes(item.priority), + ).length, + [allActiveItems], + ); + + const selectedItem = useMemo( + () => items.find((i) => i.id === selectedItemId), + [items, selectedItemId], + ); + + const isGeneralSelected = + !!generalThread?.id && selectedItemId === generalThread.id; + + useEffect(() => { + if (pendingResolveId) { + const timeout = setTimeout(() => { + resolveItem(pendingResolveId); + setJustResolvedId(pendingResolveId); + setPendingResolveId(null); + }, 400); // Wait for animation + return () => clearTimeout(timeout); + } + }, [pendingResolveId, resolveItem]); + + useEffect(() => { + if (justResolvedId) { + const timeout = setTimeout(() => { + // Find next active item or clear selection + const remainingActive = items.filter( + (item) => !isResolvedItem(item) && item.id !== justResolvedId, + ); + if (remainingActive.length > 0) { + setSelectedItemId(remainingActive[0].id); + } else if (generalThread?.id) { + setSelectedItemId(generalThread.id); + } else { + setSelectedItemId(null); + } + setJustResolvedId(null); + }, 300); + return () => clearTimeout(timeout); + } + }, [justResolvedId, items]); + + const handleResolve = useCallback( + (itemId?: string) => { + const idToResolve = itemId || selectedItem?.id; + if (!idToResolve || pendingResolveId) return; + if (!showResolved) { + setShowResolved(true); + } + setPendingResolveId(idToResolve); + }, + [selectedItem, pendingResolveId, showResolved], + ); + + const handleQuickAction = useCallback( + (itemId?: string) => { + const idToResolve = itemId || selectedItem?.id; + if (!idToResolve || pendingResolveId) return; + if (!showResolved) { + setShowResolved(true); + } + setPendingResolveId(idToResolve); + }, + [selectedItem, pendingResolveId, showResolved], + ); + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if typing in input + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) { + return; + } + + // Skip if modal is open + if (commandOpen || helpOpen) { + if (e.key === 'Escape') { + setCommandOpen(false); + setHelpOpen(false); + } + return; + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setCommandOpen(true); + return; + } + + switch (e.key.toLowerCase()) { + case 'j': { + e.preventDefault(); + const jIdx = activeItems.findIndex((i) => i.id === selectedItemId); + if (jIdx < activeItems.length - 1) { + setSelectedItemId(activeItems[jIdx + 1].id); + } + break; + } + case 'k': { + e.preventDefault(); + const kIdx = activeItems.findIndex((i) => i.id === selectedItemId); + if (kIdx > 0) { + setSelectedItemId(activeItems[kIdx - 1].id); + } + break; + } + case 'e': + e.preventDefault(); + handleResolve(); + break; + case 'q': + e.preventDefault(); + handleQuickAction(); + break; + case ',': + e.preventDefault(); + window.location.href = '/inbox/settings'; + break; + case '?': + e.preventDefault(); + setHelpOpen(true); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ + selectedItemId, + activeItems, + handleResolve, + handleQuickAction, + commandOpen, + helpOpen, + ]); + + // Handle command + const handleCommand = useCallback( + (cmd: string) => { + switch (cmd) { + case 'resolve': + handleResolve(); + break; + case 'quick-action': + handleQuickAction(); + break; + case 'settings': + window.location.href = '/inbox/settings'; + break; + } + }, + [handleResolve, handleQuickAction], + ); + + // Auto-select first item + useEffect(() => { + if (!selectedItemId && activeItems.length > 0) { + setSelectedItemId(activeItems[0].id); + return; + } + + if (!selectedItemId && activeItems.length === 0 && generalThread?.id) { + setSelectedItemId(generalThread.id); + } + }, [selectedItemId, activeItems, generalThread]); + + return ( +
+ {/* Minimal Sidebar */} + + + {/* Item List */} +
+ {/* Header */} +
+
+
+

Inbox

+

+ {allActiveItems.length} active + {allResolvedItems.length > 0 + ? ` · ${allResolvedItems.length} resolved` + : ''} +

+
+
+
+ + +
+
+
+ +
+
+ + {(Object.keys(typeConfig) as InboxItemType[]).map((type) => { + const count = allActiveItems.filter( + (item) => item.type === type, + ).length; + return ( + + ); + })} +
+ {/* Fade mask at the end */} +
+
+ + {hasLinearIntegration && allActiveItems.length > 0 && ( +
+
+
+ Blockers + +
+

+ {typeCounts.blocker} +

+
+
+
+ Drift + +
+

+ {typeCounts.drift} +

+
+
+
+ Risks + +
+

+ {typeCounts.risk} +

+
+
+
+ High priority + +
+

+ {urgentCount} +

+
+
+ )} +
+ + {/* Items List */} +
+ {generalThread && ( +
setSelectedItemId(generalThread.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedItemId(generalThread.id); + } + }} + className={cn( + 'w-full text-left p-3 rounded-xl mb-2 border border-border/50 bg-muted/20 transition-shadow', + selectedItemId === generalThread.id + ? 'ring-1 ring-border' + : 'hover:ring-1 ring-border', + )} + > +
+
+ +
+
+

+ {generalThread.title} +

+

+ Always-on Linea thread for quick questions and planning. +

+
+ {generalThread.updatedAt && ( + + + + )} +
+
+ )} + {hasLinearIntegration === false ? ( +
+
+ +
+

+ Get started with Linea +

+

+ Connect Linear to surface blockers, drift, and hidden work +

+
+ + + +
+
+ ) : hasLinearIntegration === undefined ? ( +
+
+ +
+

+ Loading your workspace +

+

+ Fetching integrations and inbox items +

+
+ ) : allActiveItems.length === 0 && !showResolved ? ( +
+
+ +
+

All caught up

+

+ No items need attention +

+
+ ) : activeItems.length === 0 && !showResolved ? ( +
+
+ +
+

+ No items for this filter +

+

+ Try another type or show resolved +

+
+ ) : ( + <> + {activeItems.map((item) => ( +
setSelectedItemId(item.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedItemId(item.id); + } + }} + className={cn( + 'w-full text-left p-3 rounded-xl mb-1 transition-shadow group cursor-pointer', + selectedItemId === item.id + ? 'ring-1 ring-border' + : 'hover:ring-1 ring-border', + pendingResolveId === item.id && 'animate-resolve', + )} + > +
+ {pendingResolveId === item.id ? ( +
+ +
+ ) : ( +
+ )} +
+

+ {item.title} +

+

+ {item.summary} +

+
+ + {priorityConfig[item.priority]?.label} + + + + +
+
+ +
+
+ ))} + + {showResolved && resolvedItems.length > 0 && ( + <> +
+

+ Resolved ({resolvedItems.length}) +

+
+ {resolvedItems.map((item) => ( +
setSelectedItemId(item.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedItemId(item.id); + } + }} + className={cn( + 'w-full text-left p-3 rounded-xl mb-1 transition-shadow opacity-50 cursor-pointer', + selectedItemId === item.id + ? 'bg-accent/50 ring-1 ring-border' + : 'hover:bg-accent/30', + )} + > +
+
+ +
+
+

+ {item.title} +

+

+ +

+
+
+
+ ))} + + )} + + )} +
+ + {/* Keyboard hint */} +
+ +
+
+ + {/* Main Chat Panel */} +
+ {selectedItem && isResolvedItem(selectedItem) ? ( + <> +
+
+
+ +
+
+

+ {selectedItem.title} +

+

+ Resolved · ago +

+
+
+
+ + Archived + + +
+
+ +
+
+

+ This item is archived +

+

+ Restore the item to continue the conversation or take action. +

+ {selectedItem.summary && ( +
+ {selectedItem.summary} +
+ )} +
+
+ + ) : selectedItem ? ( + <> + {/* Header */} +
+
+
+ {(() => { + const IconComponent = typeConfig[selectedItem.type].icon; + return ( + + ); + })()} +
+
+

+ {selectedItem.title} +

+

+ ago +

+
+
+
+ + {priorityConfig[selectedItem.priority].label} + + {!isResolvedItem(selectedItem) ? ( + + ) : ( + + Resolved + + )} +
+
+ + {/* Linea Chat Thread */} + + + ) : isGeneralSelected && generalThread ? ( + <> +
+
+
+ +
+
+

+ {generalThread.title} +

+

+ Always-on workspace thread +

+
+
+ + Main + +
+ + + + ) : !integrationsReady ? ( +
+
+
+ +
+

+ Loading your workspace +

+

+ Syncing integrations and inbox items... +

+
+
+ ) : !hasLinearIntegration ? ( +
+
+
+ +
+

+ Welcome to your Execution Inbox +

+

+ Connect Linear in Settings to surface blockers, stalled work, + and priority drift. Linea keeps you ahead without dashboard + noise. +

+
+ +
+
+
+ +

Blocker Detection

+

+ Get notified when work is stuck +

+
+
+ +

Priority Drift

+

+ Catch shifting priorities early +

+
+
+ +

Hidden Impact

+

+ Surface invisible contributions +

+
+
+ +

AI Insights

+

+ Get context-aware suggestions +

+
+
+
+
+ ) : hasLinearIntegration === undefined ? ( +
+
+
+ +
+

+ Loading Linea +

+

+ Syncing your workspace signals +

+
+
+ ) : ( + 0} + hasResolvedItems={allResolvedItems.length > 0} + /> + )} +
+ + {/* Command Palette */} + setCommandOpen(false)} + onCommand={handleCommand} + /> + + {/* Keyboard Help */} + setHelpOpen(false)} /> +
+ ); +} diff --git a/apps/customer-ui/src/app/inbox/layout.tsx b/apps/customer-ui/src/app/inbox/layout.tsx deleted file mode 100644 index 3065b00..0000000 --- a/apps/customer-ui/src/app/inbox/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type React from 'react'; -import { InboxProvider } from '@launchline/ui/lib/inbox-store'; - -export default function InboxLayout({ - children, -}: { - children: React.ReactNode; -}) { - return {children}; -} diff --git a/apps/customer-ui/src/app/inbox/page.tsx b/apps/customer-ui/src/app/inbox/page.tsx index 30a2ca1..cdcc0a6 100644 --- a/apps/customer-ui/src/app/inbox/page.tsx +++ b/apps/customer-ui/src/app/inbox/page.tsx @@ -1,1127 +1,13 @@ -'use client'; +import { redirect } from 'next/navigation'; +import { getCurrentUser } from '../../lib/server-auth'; +import InboxClient from './inbox-client'; -import { useState, useEffect, useCallback, useMemo } from 'react'; -import Link from 'next/link'; -import { LogoIcon } from '@launchline/ui/components/logo'; -import { Button } from '@launchline/ui/components/ui/button'; -import { Badge } from '@launchline/ui/components/ui/badge'; -import { cn } from '@launchline/ui/lib/utils'; +export default async function InboxPage() { + const user = await getCurrentUser(); -// Define inbox item types locally (these will be stored as thread metadata) -export type InboxItemType = 'blocker' | 'drift' | 'update' | 'coverage'; -export type LinkedContext = any; // TODO: Import from a shared types file + if (!user) { + redirect('/login'); + } -// Assistant-UI imports -import { - ThreadPrimitive, - ComposerPrimitive, - MessagePrimitive, - ActionBarPrimitive, - AssistantIf, - useAssistantState, - useAssistantApi, -} from '@assistant-ui/react'; - -// Custom Runtime Provider -import { LaunchlineRuntimeProvider } from '@launchline/ui'; - -// Tool UIs -import { - SearchMemoriesTool, - GetInboxItemsTool, - UpdateLinearTicketToolUI, - SendSlackMessageToolUI, - GenerateProjectUpdateTool, - WriteTodosToolUI, - InternetSearchToolUI, -} from '@launchline/ui/components/tools/linea/LineaTools'; - -import { - Inbox, - Settings, - Check, - AlertTriangle, - TrendingDown, - FileText, - Zap, - ArrowUp, - ArrowDown, - Minus, - Send, - Command, - Eye, - EyeOff, - Sparkles, - Clock, - ArrowUpDown, - X, - User, - ChevronRight, - ArrowDownIcon, - RefreshCwIcon, - CopyIcon, - CheckIcon, - SquareIcon, -} from 'lucide-react'; - -// Priority config -const priorityConfig: Record< - string, - { label: string; color: string; icon: typeof ArrowUp } -> = { - critical: { label: 'Critical', color: 'text-rose-400', icon: ArrowUp }, - high: { label: 'High', color: 'text-amber-400', icon: ArrowUp }, - medium: { label: 'Medium', color: 'text-foreground/60', icon: Minus }, - low: { label: 'Low', color: 'text-foreground/40', icon: ArrowDown }, -}; - -// Type config -const typeConfig: Record< - InboxItemType, - { label: string; icon: typeof AlertTriangle; color: string; bgColor: string } -> = { - blocker: { - label: 'Blocker', - icon: AlertTriangle, - color: 'text-rose-400', - bgColor: 'bg-rose-500/15', - }, - drift: { - label: 'Drift', - icon: TrendingDown, - color: 'text-amber-400', - bgColor: 'bg-amber-500/15', - }, - update: { - label: 'Update', - icon: FileText, - color: 'text-sky-400', - bgColor: 'bg-sky-500/15', - }, - coverage: { - label: 'Coverage', - icon: Zap, - color: 'text-violet-400', - bgColor: 'bg-violet-500/15', - }, -}; - -// TimeAgo component (hydration safe) -function TimeAgo({ date }: { date: Date }) { - const [timeAgo, setTimeAgo] = useState(''); - - useEffect(() => { - const calculateTimeAgo = () => { - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffMins < 1) return 'just now'; - if (diffMins < 60) return `${diffMins}m`; - if (diffHours < 24) return `${diffHours}h`; - return `${diffDays}d`; - }; - - setTimeAgo(calculateTimeAgo()); - const interval = setInterval(() => setTimeAgo(calculateTimeAgo()), 60000); - return () => clearInterval(interval); - }, [date]); - - if (!timeAgo) return ...; - return {timeAgo}; -} - -// ============================================================================ -// LINEA ASSISTANT-UI THREAD COMPONENT -// ============================================================================ - -/** - * InboxLineaThread - Assistant-UI powered chat for inbox items - * - * Uses the selected thread from the runtime (each inbox item is a thread). - */ -function InboxLineaThread({ - itemId, - itemContext, -}: { - itemId: string; - itemContext: { - title: string; - summary: string; - type: InboxItemType; - priority: string; - linkedContexts?: LinkedContext[]; - }; -}) { - const api = useAssistantApi(); - - // Switch to the thread for this inbox item - useEffect(() => { - if (itemId) { - api.threads().switchToThread(itemId); - } - }, [itemId, api]); - - return ( -
- {/* Thread Messages - scrollable area */} - - - {/* Spacer to push messages down */} - !thread.isEmpty}> -
- - -
- thread.isEmpty}> - - - - -
- - -
- - - -
-
- - - - {/* Composer - sticky at bottom */} -
- -
- - {/* Register Tool UIs */} - - - - {/* Human-in-the-loop Approval UIs */} - - - - {/* DeepAgents built-in write_todos for task planning */} - -
- ); -} - -// ============================================================================ - -// Welcome message with inbox context -function InboxThreadWelcome({ - context, -}: { - context: { - title: string; - type: InboxItemType; - priority: string; - summary: string; - }; -}) { - const typeInfo = typeConfig[context.type]; - - return ( -
-
- -
-

Hi, I'm Linea

-

- I'm here to help you with this {context.type}. Ask me anything or - use the suggested actions below. -

- - {/* Quick action suggestions */} -
- - - - - - - - - -
-
- ); -} - -// User message component for inbox thread -function InboxUserMessage() { - return ( - -
-
- -
-
-
- -
-
- ); -} - -// Assistant message component for inbox thread -function InboxAssistantMessage() { - return ( - -
- -
-
-
- -
- {/* Fixed height container to prevent layout jump */} -
- -
-
-
- ); -} - -// Action bar for assistant messages - uses opacity for smooth show/hide -function InboxAssistantActionBar() { - return ( - - - - - - - - - ); -} - -// Composer for inbox thread -function InboxComposer() { - return ( - -
- -
- !thread.isRunning}> - - - - - thread.isRunning}> - - - - -
-
-
- ); -} - -// ============================================================================ -// END LINEA ASSISTANT-UI COMPONENTS -// ============================================================================ - -// Command Palette -function CommandPalette({ - open, - onClose, - onCommand, -}: { - open: boolean; - onClose: () => void; - onCommand: (cmd: string) => void; -}) { - const [search, setSearch] = useState(''); - - const commands = [ - { id: 'resolve', label: 'Resolve item', shortcut: 'E' }, - { id: 'assign', label: 'Assign to...', shortcut: 'A' }, - { id: 'priority', label: 'Set priority', shortcut: 'P' }, - { id: 'quick-action', label: 'Quick action (auto)', shortcut: 'Q' }, - { id: 'next', label: 'Next item', shortcut: 'J' }, - { id: 'prev', label: 'Previous item', shortcut: 'K' }, - { id: 'settings', label: 'Open settings', shortcut: ',' }, - ]; - - const filtered = commands.filter((c) => - c.label.toLowerCase().includes(search.toLowerCase()), - ); - - useEffect(() => { - if (open) setSearch(''); - }, [open]); - - if (!open) return null; - - return ( -
-
-
-
- - setSearch(e.target.value)} - placeholder="Type a command..." - className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" - /> -
-
- {filtered.map((cmd) => ( - - ))} -
-
-
- ); -} - -// Keyboard shortcuts help -function KeyboardHelp({ - open, - onClose, -}: { - open: boolean; - onClose: () => void; -}) { - if (!open) return null; - - const shortcuts = [ - { key: 'J / K', desc: 'Navigate items' }, - { key: 'E', desc: 'Resolve selected' }, - { key: 'A', desc: 'Assign to...' }, - { key: 'P', desc: 'Set priority' }, - { key: 'Q', desc: 'Quick action (auto)' }, - { key: '⌘ K', desc: 'Command palette' }, - { key: ',', desc: 'Settings' }, - { key: '?', desc: 'This help' }, - ]; - - return ( -
-
-
-
-

Keyboard shortcuts

- -
-
- {shortcuts.map((s) => ( -
- {s.desc} - - {s.key} - -
- ))} -
-
-
- ); -} - -// Main page component wrapped with runtime provider -export default function InboxPage() { - return ( - - - - ); -} - -// Inner component that uses assistant-ui hooks -function InboxPageContent() { - // Get threads from assistant-ui runtime using new API - const threads = useAssistantState(({ threads }) => threads || []); - - const [selectedItemId, setSelectedItemId] = useState(null); - const [filterType, setFilterType] = useState<'all' | InboxItemType>('all'); - const [showResolved, setShowResolved] = useState(false); - const [sortBy, setSortBy] = useState<'priority' | 'time'>('priority'); - const [commandOpen, setCommandOpen] = useState(false); - const [helpOpen, setHelpOpen] = useState(false); - const [justResolvedId, setJustResolvedId] = useState(null); - const [pendingResolveId, setPendingResolveId] = useState(null); - - // Map threads to inbox items format - const items = useMemo(() => { - return threads.threadItems.map((thread: any) => ({ - id: thread.threadId, - externalId: thread.threadId, - type: (thread.metadata?.inboxItemType || 'update') as InboxItemType, - status: thread.metadata?.inboxStatus || 'new', - priority: thread.metadata?.inboxPriority || 'medium', - title: thread.title || 'Untitled', - summary: thread.metadata?.summary || '', - timestamp: thread.metadata?.createdAt - ? new Date(thread.metadata.createdAt) - : new Date(), - projectId: thread.metadata?.projectId || '', - featureId: thread.metadata?.featureId, - linkedContexts: thread.metadata?.linkedContexts || [], - messages: [], - executionLogs: [], - })); - }, [threads]); - - // Resolve function using runtime - const resolveItem = useCallback((itemId: string) => { - // TODO: Implement delete via GraphQL mutation (deleteThread) - // This will call the backend to archive/delete the thread - console.log('Resolving item:', itemId); - // Note: runtime.threadList doesn't have a delete method - // We need to implement this via GraphQL mutation - }, []); - - // Filter and sort items - const filteredItems = useMemo(() => { - const result = items.filter((item) => { - const isResolved = [ - 'actioned', - 'auto-resolved', - 'closed', - 'dismissed', - ].includes(item.status); - if (!showResolved && isResolved) return false; - if (filterType !== 'all' && item.type !== filterType) return false; - return true; - }); - - // Sort - result.sort((a, b) => { - if (sortBy === 'priority') { - const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; - const diff = - (priorityOrder[a.priority as keyof typeof priorityOrder] || 2) - - (priorityOrder[b.priority as keyof typeof priorityOrder] || 2); - if (diff !== 0) return diff; - } - return b.timestamp.getTime() - a.timestamp.getTime(); - }); - - return result; - }, [items, filterType, showResolved, sortBy]); - - // Active vs resolved counts - const activeItems = useMemo( - () => - filteredItems.filter( - (i) => - !['actioned', 'auto-resolved', 'closed', 'dismissed'].includes( - i.status, - ), - ), - [filteredItems], - ); - const resolvedItems = useMemo( - () => - filteredItems.filter((i) => - ['actioned', 'auto-resolved', 'closed', 'dismissed'].includes(i.status), - ), - [filteredItems], - ); - - const selectedItem = useMemo( - () => items.find((i) => i.id === selectedItemId), - [items, selectedItemId], - ); - - useEffect(() => { - if (pendingResolveId) { - const timeout = setTimeout(() => { - resolveItem(pendingResolveId); - setJustResolvedId(pendingResolveId); - setPendingResolveId(null); - }, 400); // Wait for animation - return () => clearTimeout(timeout); - } - }, [pendingResolveId, resolveItem]); - - useEffect(() => { - if (justResolvedId) { - const timeout = setTimeout(() => { - // Find next active item or clear selection - const remainingActive = items.filter( - (i) => - !['actioned', 'auto-resolved', 'closed', 'dismissed'].includes( - i.status, - ) && i.id !== justResolvedId, - ); - if (remainingActive.length > 0) { - setSelectedItemId(remainingActive[0].id); - } else { - setSelectedItemId(null); - } - setJustResolvedId(null); - }, 300); - return () => clearTimeout(timeout); - } - }, [justResolvedId, items]); - - const handleResolve = useCallback(() => { - if (!selectedItem || pendingResolveId) return; - setPendingResolveId(selectedItem.id); - }, [selectedItem, pendingResolveId]); - - const handleQuickAction = useCallback(() => { - if (!selectedItem || pendingResolveId) return; - setPendingResolveId(selectedItem.id); - }, [selectedItem, pendingResolveId]); - - // Keyboard navigation - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Ignore if typing in input - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement - ) { - return; - } - - // Skip if modal is open - if (commandOpen || helpOpen) { - if (e.key === 'Escape') { - setCommandOpen(false); - setHelpOpen(false); - } - return; - } - - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - setCommandOpen(true); - return; - } - - switch (e.key.toLowerCase()) { - case 'j': { - e.preventDefault(); - const jIdx = activeItems.findIndex((i) => i.id === selectedItemId); - if (jIdx < activeItems.length - 1) { - setSelectedItemId(activeItems[jIdx + 1].id); - } - break; - } - case 'k': { - e.preventDefault(); - const kIdx = activeItems.findIndex((i) => i.id === selectedItemId); - if (kIdx > 0) { - setSelectedItemId(activeItems[kIdx - 1].id); - } - break; - } - case 'e': - e.preventDefault(); - handleResolve(); - break; - case 'q': - e.preventDefault(); - handleQuickAction(); - break; - case ',': - e.preventDefault(); - window.location.href = '/inbox/settings'; - break; - case '?': - e.preventDefault(); - setHelpOpen(true); - break; - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [ - selectedItemId, - activeItems, - handleResolve, - handleQuickAction, - commandOpen, - helpOpen, - ]); - - // Handle command - const handleCommand = useCallback( - (cmd: string) => { - switch (cmd) { - case 'resolve': - handleResolve(); - break; - case 'quick-action': - handleQuickAction(); - break; - case 'settings': - window.location.href = '/inbox/settings'; - break; - } - }, - [handleResolve, handleQuickAction], - ); - - // Auto-select first item - useEffect(() => { - if (!selectedItemId && activeItems.length > 0) { - setSelectedItemId(activeItems[0].id); - } - }, [selectedItemId, activeItems]); - - return ( -
- {/* Minimal Sidebar */} - - - {/* Item List */} -
- {/* Header */} -
-
-

Inbox

-
- - -
-
- -
-
- - {(Object.keys(typeConfig) as InboxItemType[]).map((type) => { - const count = activeItems.filter((i) => i.type === type).length; - return ( - - ); - })} -
- {/* Fade mask at the end */} -
-
-
- - {/* Items List */} -
- {activeItems.length === 0 && !showResolved ? ( -
-
- -
-

All caught up

-

- No items need attention -

-
- ) : ( - <> - {activeItems.map((item) => ( -
setSelectedItemId(item.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setSelectedItemId(item.id); - } - }} - className={cn( - 'w-full text-left p-3 rounded-xl mb-1 transition-all group cursor-pointer', - selectedItemId === item.id - ? 'ring-1 ring-border' - : 'hover:ring-1 ring-border', - pendingResolveId === item.id && 'animate-resolve', - )} - > -
- {pendingResolveId === item.id ? ( -
- -
- ) : ( -
- )} -
-

- {item.title} -

-

- {item.summary} -

-
- - {priorityConfig[item.priority]?.label} - - - - -
-
- -
-
- ))} - - {showResolved && resolvedItems.length > 0 && ( - <> -
-

- Resolved ({resolvedItems.length}) -

-
- {resolvedItems.map((item) => ( -
setSelectedItemId(item.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setSelectedItemId(item.id); - } - }} - className={cn( - 'w-full text-left p-3 rounded-xl mb-1 transition-all opacity-50 cursor-pointer', - selectedItemId === item.id - ? 'bg-accent/50 ring-1 ring-border' - : 'hover:bg-accent/30', - )} - > -
-
- -
-
-

- {item.title} -

-

- -

-
-
-
- ))} - - )} - - )} -
- - {/* Keyboard hint */} -
- -
-
- - {/* Main Chat Panel */} -
- {selectedItem ? ( - <> - {/* Header */} -
-
-
- {(() => { - const IconComponent = typeConfig[selectedItem.type].icon; - return ( - - ); - })()} -
-
-

- {selectedItem.title} -

-

- ago -

-
-
-
- - {priorityConfig[selectedItem.priority].label} - - {!['actioned', 'auto-resolved', 'closed', 'dismissed'].includes( - selectedItem.status, - ) ? ( - - ) : ( - - Resolved - - )} -
-
- - {/* Linea Chat Thread */} - - - ) : ( -
-
-
- -
-

- No thread selected -

-

- Select an item from the list to view details -

-
-
- )} -
- - {/* Command Palette */} - setCommandOpen(false)} - onCommand={handleCommand} - /> - - {/* Keyboard Help */} - setHelpOpen(false)} /> -
- ); + return ; } diff --git a/apps/customer-ui/src/app/inbox/settings/page.tsx b/apps/customer-ui/src/app/inbox/settings/page.tsx index 80f388d..a3267a2 100644 --- a/apps/customer-ui/src/app/inbox/settings/page.tsx +++ b/apps/customer-ui/src/app/inbox/settings/page.tsx @@ -1,448 +1,13 @@ -'use client'; +import { redirect } from 'next/navigation'; +import { getCurrentUser } from '../../../lib/server-auth'; +import SettingsClient from './settings-client'; -import { useState } from 'react'; -import Link from 'next/link'; -import { LogoIcon } from '@launchline/ui/components/logo'; -import { Button } from '@launchline/ui/components/ui/button'; -import { cn } from '@launchline/ui/lib/utils'; -import { - ArrowLeft, - Check, - ExternalLink, - Trash2, - RefreshCw, - AlertTriangle, - Keyboard, - X, -} from 'lucide-react'; +export default async function SettingsPage() { + const user = await getCurrentUser(); -type Integration = { - id: string; - name: string; - description: string; - icon: string; - connected: boolean; - lastSync?: string; - workspace?: string; -}; + if (!user) { + redirect('/login'); + } -const INTEGRATIONS: Integration[] = [ - { - id: 'slack', - name: 'Slack', - description: 'Receive notifications and discuss items in Slack channels', - icon: 'S', - connected: true, - lastSync: '2 minutes ago', - workspace: 'acme-team', - }, - { - id: 'github', - name: 'GitHub', - description: 'Track PRs, issues, and code changes', - icon: 'G', - connected: true, - lastSync: '5 minutes ago', - workspace: 'acme-org', - }, - { - id: 'linear', - name: 'Linear', - description: 'Sync issues, projects, and roadmap items', - icon: 'L', - connected: true, - lastSync: '1 minute ago', - workspace: 'ACME', - }, - { - id: 'figma', - name: 'Figma', - description: 'Link design files and track design changes', - icon: 'F', - connected: false, - }, - { - id: 'notion', - name: 'Notion', - description: 'Connect docs, wikis, and knowledge bases', - icon: 'N', - connected: false, - }, - { - id: 'jira', - name: 'Jira', - description: 'Sync Jira issues and sprints', - icon: 'J', - connected: false, - }, - { - id: 'google', - name: 'Google Workspace', - description: 'Calendar, Docs, and Gmail integration', - icon: 'G', - connected: false, - }, -]; - -export default function SettingsPage() { - const [integrations, setIntegrations] = useState(INTEGRATIONS); - const [showDeleteConfirm, setShowDeleteConfirm] = useState( - null, - ); - const [activeTab, setActiveTab] = useState< - 'integrations' | 'preferences' | 'data' - >('integrations'); - - const handleConnect = (id: string) => { - setIntegrations((prev) => - prev.map((i) => - i.id === id - ? { - ...i, - connected: true, - lastSync: 'Just now', - workspace: `${i.name.toLowerCase()}-workspace`, - } - : i, - ), - ); - }; - - const handleDisconnect = (id: string) => { - setIntegrations((prev) => - prev.map((i) => - i.id === id - ? { - ...i, - connected: false, - lastSync: undefined, - workspace: undefined, - } - : i, - ), - ); - setShowDeleteConfirm(null); - }; - - const handleSync = (id: string) => { - setIntegrations((prev) => - prev.map((i) => (i.id === id ? { ...i, lastSync: 'Just now' } : i)), - ); - }; - - const connectedCount = integrations.filter((i) => i.connected).length; - - return ( -
- {/* Header */} -
-
-
- - - -
- -

Settings

-
-
-
-
- - {/* Content */} -
- {/* Tabs */} -
- {(['integrations', 'preferences', 'data'] as const).map((tab) => ( - - ))} -
- - {activeTab === 'integrations' && ( -
- {/* Connected */} -
-

- Connected ({connectedCount}) -

-
- {integrations - .filter((i) => i.connected) - .map((integration) => ( -
-
- {integration.icon} -
-
-
-

{integration.name}

- - - Connected - -
-

- {integration.workspace} -

-

- Last synced: {integration.lastSync} -

-
-
- - -
-
- ))} -
-
- - {/* Available */} -
-

- Available integrations -

-
- {integrations - .filter((i) => !i.connected) - .map((integration) => ( -
-
- {integration.icon} -
-
-

{integration.name}

-

- {integration.description} -

-
- -
- ))} -
-
- - {/* Request Integration */} -
-

- Don't see your tool? -

- -
-
- )} - - {activeTab === 'preferences' && ( -
-
-

- Notifications -

-
- {[ - { - id: 'blockers', - label: 'Blockers', - desc: 'Get notified about blocked items', - enabled: true, - }, - { - id: 'drift', - label: 'Priority drift', - desc: 'Alert when priorities shift', - enabled: true, - }, - { - id: 'updates', - label: 'Project updates', - desc: 'Weekly update reminders', - enabled: false, - }, - { - id: 'coverage', - label: 'Test coverage', - desc: 'Coverage threshold alerts', - enabled: true, - }, - ].map((pref) => ( -
-
-

{pref.label}

-

- {pref.desc} -

-
- -
- ))} -
-
- -
-

- Keyboard shortcuts -

-
-
- -
-

Enable keyboard shortcuts

-

- Use J/K to navigate, E to resolve, Q for quick action -

-
- -
-
-
-
- )} - - {activeTab === 'data' && ( -
-
-

Export your data

-

- Download all your inbox items, activity logs, and settings as a - JSON file. -

- -
- -
-
- -
-

- Danger zone -

-

- Permanently delete your account and all associated data. - This action cannot be undone. -

- -
-
-
-
- )} -
- - {/* Disconnect Confirmation Modal */} - {showDeleteConfirm && ( -
-
setShowDeleteConfirm(null)} - /> -
- -

- Disconnect integration? -

-

- This will remove access to{' '} - {integrations.find((i) => i.id === showDeleteConfirm)?.name}. You - can reconnect anytime. -

-
- - -
-
-
- )} -
- ); + return ; } diff --git a/apps/customer-ui/src/app/inbox/settings/settings-client.tsx b/apps/customer-ui/src/app/inbox/settings/settings-client.tsx new file mode 100644 index 0000000..89dcd76 --- /dev/null +++ b/apps/customer-ui/src/app/inbox/settings/settings-client.tsx @@ -0,0 +1,1399 @@ +'use client'; + +import type React from 'react'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { gql } from '@apollo/client'; +import { useMutation, useQuery } from '@apollo/client/react'; +import { LogoIcon } from '@launchline/ui/components/logo'; +import { Button } from '@launchline/ui/components/ui/button'; +import { cn } from '@launchline/ui/lib/utils'; +import { + ArrowLeft, + Check, + ExternalLink, + Trash2, + AlertTriangle, + Keyboard, + X, + Clock, + Brain, + Sparkles, + Mail, + CalendarDays, +} from 'lucide-react'; + +const API_BASE = (process.env.NEXT_PUBLIC_API_URL || '').replace(/\/$/, ''); + +const INTEGRATIONS_QUERY = gql` + query Integrations { + integrations { + integrations { + id + type + status + name + externalAccountName + externalOrganizationName + createdAt + lastSyncAt + } + } + } +`; + +const DELETE_INTEGRATION_MUTATION = gql` + mutation DeleteIntegration($input: DeleteIntegrationInput!) { + deleteIntegration(input: $input) + } +`; + +const LINEA_DATA_QUERY = gql` + query LineaData { + lineaWorkspacePrompt { + prompt + updatedAt + updatedBy + version + } + lineaJobs { + jobs { + id + name + type + status + cron + timezone + nextRunAt + runAt + task + } + } + lineaMemories(input: { limit: 30 }) { + memories { + id + namespace + category + summary + importance + updatedAt + } + } + lineaSkills { + skills { + id + name + content + updatedAt + } + } + } +`; + +const LINEA_HEARTBEAT_SETTINGS_QUERY = gql` + query LineaHeartbeatSettings { + lineaHeartbeatSettings { + enabled + summaryDelivery + slackChannelId + quietHoursStart + quietHoursEnd + timezone + lastRunAt + updatedAt + updatedBy + } + } +`; + +const UPDATE_WORKSPACE_PROMPT_MUTATION = gql` + mutation UpdateLineaWorkspacePrompt( + $input: UpdateLineaWorkspacePromptInput! + ) { + updateLineaWorkspacePrompt(input: $input) { + prompt + updatedAt + updatedBy + version + } + } +`; + +const UPSERT_LINEA_SKILL_MUTATION = gql` + mutation UpsertLineaSkill($input: UpsertLineaSkillInput!) { + upsertLineaSkill(input: $input) { + id + name + content + updatedAt + } + } +`; + +const UPDATE_LINEA_HEARTBEAT_SETTINGS_MUTATION = gql` + mutation UpdateLineaHeartbeatSettings( + $input: UpdateLineaHeartbeatSettingsInput! + ) { + updateLineaHeartbeatSettings(input: $input) { + enabled + summaryDelivery + slackChannelId + quietHoursStart + quietHoursEnd + timezone + lastRunAt + updatedAt + updatedBy + } + } +`; + +type Integration = { + id: string; + type: string; + status: string; + name?: string; + externalAccountName?: string; + externalOrganizationName?: string; + createdAt: string; + lastSyncAt?: string; +}; + +type IntegrationDefinition = { + type: 'linear' | 'slack' | 'github' | 'google'; + name: string; + description: string; + icon: React.ReactNode; + connectable: boolean; + connectLabel: string; +}; + +type LineaJob = { + id: string; + name: string; + type: string; + status: string; + cron?: string; + timezone?: string; + nextRunAt?: string; + runAt?: string; + task?: string; +}; + +type LineaMemory = { + id: string; + namespace: string; + category: string; + summary: string; + importance: number; + updatedAt?: string; +}; + +type LineaSkill = { + id: string; + name: string; + content: string; + updatedAt?: string; +}; + +type LineaWorkspacePrompt = { + prompt: string; + updatedAt: string; + updatedBy?: string; + version: number; +}; + +type LineaHeartbeatSettings = { + enabled: boolean; + summaryDelivery: string; + slackChannelId?: string; + quietHoursStart?: string; + quietHoursEnd?: string; + timezone?: string; + lastRunAt?: string; + updatedAt?: string; + updatedBy?: string; +}; + +const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [ + { + type: 'slack', + name: 'Slack', + description: 'Primary interface for Linea (DMs, mentions, digests)', + icon: ( +
+ + + +
+ ), + connectable: true, + connectLabel: 'Connect Slack (recommended)', + }, + { + type: 'linear', + name: 'Linear', + description: 'Issues, projects, cycles, and blockers', + icon: Linear, + connectable: true, + connectLabel: 'Connect Linear', + }, + { + type: 'google', + name: 'Google Workspace', + description: 'Gmail + Calendar signals for Linea', + icon: ( +
+ + +
+ ), + connectable: true, + connectLabel: 'Connect Google', + }, + { + type: 'github', + name: 'GitHub', + description: 'PRs, commits, and engineering signal', + icon: GH, + connectable: true, + connectLabel: 'Install GitHub App', + }, +]; + +const normalize = (value?: string) => (value || '').toLowerCase(); + +export default function SettingsClient() { + const searchParams = useSearchParams(); + const [activeTab, setActiveTab] = useState< + 'integrations' | 'preferences' | 'data' + >('integrations'); + const [pendingDisconnectId, setPendingDisconnectId] = useState( + null, + ); + const [promptDraft, setPromptDraft] = useState(''); + const [promptDirty, setPromptDirty] = useState(false); + const [memoryFilter, setMemoryFilter] = useState('all'); + const [selectedSkillId, setSelectedSkillId] = useState(null); + const [skillName, setSkillName] = useState(''); + const [skillContent, setSkillContent] = useState(''); + const [skillDirty, setSkillDirty] = useState(false); + const skillFileRef = useRef(null); + const [heartbeatEnabled, setHeartbeatEnabled] = useState(true); + const [heartbeatDelivery, setHeartbeatDelivery] = useState< + 'inbox' | 'slack' | 'none' + >('inbox'); + const [heartbeatSlackChannel, setHeartbeatSlackChannel] = useState(''); + const [heartbeatQuietStart, setHeartbeatQuietStart] = useState(''); + const [heartbeatQuietEnd, setHeartbeatQuietEnd] = useState(''); + const [heartbeatTimezone, setHeartbeatTimezone] = useState('UTC'); + const [heartbeatDirty, setHeartbeatDirty] = useState(false); + + const { data, loading, refetch } = useQuery<{ + integrations: { integrations: Integration[] }; + }>(INTEGRATIONS_QUERY, { fetchPolicy: 'network-only' }); + + const [deleteIntegration, { loading: deleting }] = useMutation( + DELETE_INTEGRATION_MUTATION, + ); + + const { + data: lineaData, + loading: lineaLoading, + refetch: refetchLinea, + } = useQuery<{ + lineaWorkspacePrompt?: LineaWorkspacePrompt | null; + lineaJobs: { jobs: LineaJob[] }; + lineaMemories: { memories: LineaMemory[] }; + lineaSkills: { skills: LineaSkill[] }; + }>(LINEA_DATA_QUERY, { + skip: activeTab !== 'data', + fetchPolicy: 'network-only', + }); + + const [updateWorkspacePrompt, { loading: updatingPrompt }] = useMutation( + UPDATE_WORKSPACE_PROMPT_MUTATION, + ); + const [upsertLineaSkill, { loading: updatingSkill }] = useMutation<{ + upsertLineaSkill: { + id: string; + name: string; + content: string; + }; + }>(UPSERT_LINEA_SKILL_MUTATION); + + const { + data: heartbeatData, + loading: heartbeatLoading, + refetch: refetchHeartbeat, + } = useQuery<{ + lineaHeartbeatSettings: LineaHeartbeatSettings; + }>(LINEA_HEARTBEAT_SETTINGS_QUERY, { + skip: activeTab !== 'preferences', + fetchPolicy: 'network-only', + }); + + const [updateHeartbeatSettings, { loading: updatingHeartbeat }] = useMutation( + UPDATE_LINEA_HEARTBEAT_SETTINGS_MUTATION, + ); + + const integrations = data?.integrations?.integrations ?? []; + const jobs = lineaData?.lineaJobs?.jobs ?? []; + const memories = lineaData?.lineaMemories?.memories ?? []; + const skills = lineaData?.lineaSkills?.skills ?? []; + const promptRecord = lineaData?.lineaWorkspacePrompt ?? null; + const heartbeatSettings = heartbeatData?.lineaHeartbeatSettings; + + const sortedJobs = useMemo(() => { + const uniqueJobs = new Map(); + + for (const job of jobs) { + const rawId = job.id || ''; + const idParts = rawId.split(':'); + const baseId = + idParts[0] === 'repeat' || idParts[0] === 'scheduled' + ? idParts[1] || rawId + : rawId; + const fallbackKey = [ + job.name, + job.type, + job.task || '', + job.cron || '', + ].join(':'); + const key = baseId || fallbackKey; + const existing = uniqueJobs.get(key); + + if (!existing) { + uniqueJobs.set(key, job); + continue; + } + + const scoreJob = (candidate: LineaJob) => { + const status = candidate.status?.toLowerCase(); + const statusScore = + status === 'repeatable' + ? 3 + : status === 'active' || status === 'waiting' + ? 2 + : status === 'delayed' + ? 1 + : 0; + const scheduleScore = + (candidate.nextRunAt ? 1 : 0) + + (candidate.cron ? 0.5 : 0) + + (candidate.runAt ? 0.25 : 0); + + return statusScore + scheduleScore; + }; + + if (scoreJob(job) > scoreJob(existing)) { + uniqueJobs.set(key, job); + } + } + + return Array.from(uniqueJobs.values()).sort((a, b) => { + const aTime = a.nextRunAt + ? new Date(a.nextRunAt).getTime() + : a.runAt + ? new Date(a.runAt).getTime() + : Number.POSITIVE_INFINITY; + const bTime = b.nextRunAt + ? new Date(b.nextRunAt).getTime() + : b.runAt + ? new Date(b.runAt).getTime() + : Number.POSITIVE_INFINITY; + return aTime - bTime; + }); + }, [jobs]); + + const memoryNamespaces = useMemo(() => { + const set = new Set(memories.map((memory) => memory.namespace)); + return ['all', ...Array.from(set).sort()]; + }, [memories]); + + const visibleMemories = useMemo(() => { + if (memoryFilter === 'all') { + return memories; + } + + return memories.filter((memory) => memory.namespace === memoryFilter); + }, [memories, memoryFilter]); + + useEffect(() => { + if (!promptDirty && promptRecord?.prompt) { + setPromptDraft(promptRecord.prompt); + } + }, [promptRecord, promptDirty]); + + useEffect(() => { + if (heartbeatDirty || !heartbeatSettings) { + return; + } + + const delivery = ( + heartbeatSettings.summaryDelivery || 'INBOX' + ).toLowerCase() as 'inbox' | 'slack' | 'none'; + + setHeartbeatEnabled(Boolean(heartbeatSettings.enabled)); + setHeartbeatDelivery(delivery); + setHeartbeatSlackChannel(heartbeatSettings.slackChannelId || ''); + setHeartbeatQuietStart(heartbeatSettings.quietHoursStart || ''); + setHeartbeatQuietEnd(heartbeatSettings.quietHoursEnd || ''); + setHeartbeatTimezone(heartbeatSettings.timezone || 'UTC'); + }, [heartbeatDirty, heartbeatSettings]); + + useEffect(() => { + if (skillDirty) { + return; + } + + if (selectedSkillId) { + const match = skills.find((skill) => skill.id === selectedSkillId); + if (match) { + setSkillName(match.name); + setSkillContent(match.content); + } + return; + } + + if (!skillName && !skillContent && skills.length > 0) { + const first = skills[0]; + setSelectedSkillId(first.id); + setSkillName(first.name); + setSkillContent(first.content); + } + }, [skills, selectedSkillId, skillDirty, skillName, skillContent]); + + useEffect(() => { + const tabParam = searchParams.get('tab'); + if (!tabParam) { + return; + } + + if ( + tabParam === 'integrations' || + tabParam === 'preferences' || + tabParam === 'data' + ) { + setActiveTab(tabParam); + } + }, [searchParams]); + + const integrationMap = useMemo(() => { + return new Map( + integrations.map((integration) => [ + normalize(integration.type), + integration, + ]), + ); + }, [integrations]); + + const connectedIntegrations = INTEGRATION_DEFINITIONS.filter((definition) => { + const integration = integrationMap.get(definition.type); + if (!integration) return false; + return normalize(integration.status) === 'active'; + }); + + const availableIntegrations = INTEGRATION_DEFINITIONS.filter((definition) => { + const integration = integrationMap.get(definition.type); + return !integration || normalize(integration.status) !== 'active'; + }); + + const handleConnect = (type: 'linear' | 'slack' | 'google' | 'github') => { + const redirectUrl = encodeURIComponent(window.location.href); + const slackTeam = searchParams.get('slack_team'); + const slackParam = + type === 'slack' && slackTeam + ? `&team=${encodeURIComponent(slackTeam)}` + : ''; + window.location.href = `${API_BASE}/integrations/oauth/${type}/init?redirect_url=${redirectUrl}${slackParam}`; + }; + + const handleDisconnect = async (integrationId: string) => { + setPendingDisconnectId(integrationId); + try { + await deleteIntegration({ + variables: { + input: { integrationId }, + }, + }); + await refetch(); + } catch (error) { + console.error('Failed to disconnect integration:', error); + } finally { + setPendingDisconnectId(null); + } + }; + + const handleSavePrompt = async () => { + if (!promptDraft.trim()) { + return; + } + + try { + await updateWorkspacePrompt({ + variables: { + input: { prompt: promptDraft.trim() }, + }, + }); + setPromptDirty(false); + await refetchLinea(); + } catch (error) { + console.error('Failed to update workspace prompt:', error); + } + }; + + const handleSelectSkill = (skill: LineaSkill) => { + setSelectedSkillId(skill.id); + setSkillName(skill.name); + setSkillContent(skill.content); + setSkillDirty(false); + }; + + const handleNewSkill = () => { + setSelectedSkillId(null); + setSkillName(''); + setSkillContent(''); + setSkillDirty(true); + }; + + const handleSaveSkill = async () => { + if (!skillName.trim() || !skillContent.trim()) { + return; + } + + try { + const response = await upsertLineaSkill({ + variables: { + input: { + id: selectedSkillId || undefined, + name: skillName.trim(), + content: skillContent.trim(), + }, + }, + }); + + const updated = response.data?.upsertLineaSkill; + if (updated) { + setSelectedSkillId(updated.id); + setSkillName(updated.name); + setSkillContent(updated.content); + } + + setSkillDirty(false); + await refetchLinea(); + } catch (error) { + console.error('Failed to save skill:', error); + } + }; + + const handleSaveHeartbeat = async () => { + try { + await updateHeartbeatSettings({ + variables: { + input: { + enabled: heartbeatEnabled, + summaryDelivery: heartbeatDelivery.toUpperCase(), + slackChannelId: heartbeatSlackChannel.trim() || null, + quietHoursStart: heartbeatQuietStart.trim() || null, + quietHoursEnd: heartbeatQuietEnd.trim() || null, + timezone: heartbeatTimezone.trim() || null, + }, + }, + }); + setHeartbeatDirty(false); + await refetchHeartbeat(); + } catch (error) { + console.error('Failed to update heartbeat settings:', error); + } + }; + + const handleSkillFileUpload = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + if (!file) return; + + const content = await file.text(); + const name = file.name.replace(/\.[^/.]+$/, ''); + + setSelectedSkillId(null); + setSkillName(name); + setSkillContent(content); + setSkillDirty(true); + + if (skillFileRef.current) { + skillFileRef.current.value = ''; + } + }; + + const handleRefresh = async () => { + await refetch(); + if (activeTab === 'data') { + await refetchLinea(); + } + if (activeTab === 'preferences') { + await refetchHeartbeat(); + } + }; + + return ( +
+
+
+
+ + + +
+ +

Settings

+
+
+
+
+ +
+
+ + +
+ {activeTab === 'integrations' && ( +
+
+

+ Connected ({connectedIntegrations.length}) +

+ {loading ? ( +
+ Loading integrations... +
+ ) : connectedIntegrations.length === 0 ? ( +
+ No integrations connected yet. +
+ ) : ( +
+ {connectedIntegrations.map((definition) => { + const integration = integrationMap.get(definition.type); + if (!integration) return null; + + return ( +
+
+ {definition.icon} +
+
+
+

{definition.name}

+ + + Active + +
+

+ {integration.externalOrganizationName || + integration.name || + definition.description} +

+ {integration.externalAccountName && ( +

+ Connected as {integration.externalAccountName} +

+ )} +
+
+ {integration.lastSyncAt && ( + + Sync{' '} + {new Date( + integration.lastSyncAt, + ).toLocaleDateString()} + + )} + + +
+
+ ); + })} +
+ )} +
+ +
+

+ Available +

+
+ {availableIntegrations.map((definition) => ( +
+
+ {definition.icon} +
+
+

{definition.name}

+

+ {definition.description} +

+
+
+ +
+
+ ))} +
+
+
+ )} + + {activeTab === 'preferences' && ( +
+
+
+ +
+

+ Heartbeat settings +

+

+ Linea checks your workspace every 30 minutes and reports + new signals. +

+
+
+ + {heartbeatLoading && !heartbeatSettings ? ( +

+ Loading heartbeat settings... +

+ ) : ( +
+
+ + +
+

+ Summary delivery +

+ + {heartbeatDelivery === 'slack' && ( + { + setHeartbeatSlackChannel(event.target.value); + setHeartbeatDirty(true); + }} + placeholder="Slack channel ID (e.g. C0123...)" + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm" + /> + )} +
+
+ +
+
+

+ Quiet hours +

+
+ { + setHeartbeatQuietStart(event.target.value); + setHeartbeatDirty(true); + }} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm" + /> + { + setHeartbeatQuietEnd(event.target.value); + setHeartbeatDirty(true); + }} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm" + /> +
+ { + setHeartbeatTimezone(event.target.value); + setHeartbeatDirty(true); + }} + placeholder="Timezone (e.g. America/Los_Angeles)" + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm" + /> + {heartbeatSettings?.lastRunAt && ( +

+ Last run:{' '} + {new Date( + heartbeatSettings.lastRunAt, + ).toLocaleString()} +

+ )} +
+
+
+ )} + +
+ {heartbeatSettings?.updatedAt && ( + + Updated{' '} + {new Date(heartbeatSettings.updatedAt).toLocaleString()} + + )} + {heartbeatSettings?.updatedBy && ( + By {heartbeatSettings.updatedBy} + )} + +
+
+
+ )} + + {activeTab === 'data' && ( +
+
+
+
+
+ + + Agent control center + +
+

+ Linea workspace intelligence +

+

+ Review the workspace prompt, active jobs, and memory + store. This is Linea's operating context for your + team. +

+
+
+ + + + +
+
+ +
+
+

+ Scheduled jobs +

+

+ {jobs.length} +

+

+ Heartbeat + recurring tasks +

+
+
+

Memories

+

+ {memories.length} +

+

+ Team, decisions, blockers +

+
+
+

Skills

+

+ {skills.length} +

+

+ Workspace playbooks +

+
+
+

+ Prompt version +

+

+ {promptRecord?.version ?? 0} +

+

+ Last updated{' '} + {promptRecord?.updatedAt + ? new Date( + promptRecord.updatedAt, + ).toLocaleDateString() + : '—'} +

+
+
+
+ +
+
+
+ +
+

+ Workspace prompt +

+

+ Guardrails and preferences for Linea in this + workspace. +

+
+
+ + {lineaLoading && !promptRecord ? ( +

+ Loading workspace prompt... +

+ ) : ( + <> +