From 1b53d2619d772ebf3781ab9d75ac557669d3806b Mon Sep 17 00:00:00 2001 From: dkarasiewicz Date: Sat, 24 Jan 2026 18:34:14 +0100 Subject: [PATCH 1/8] LAU-31: Implement first version of product copilot --- .env.example | 30 + README.md | 303 +-- apps/core/migrations/0001_oval_sentinel.sql | 31 + .../0002_small_squadron_sinister.sql | 7 + apps/core/migrations/meta/0001_snapshot.json | 971 +++++++++ apps/core/migrations/meta/0002_snapshot.json | 934 +++++++++ apps/core/migrations/meta/_journal.json | 14 + apps/core/project.json | 6 + apps/core/src/app/app.module.ts | 11 + apps/core/src/app/configuration.ts | 73 + apps/core/src/demo-seed.ts | 157 ++ apps/customer-ui/next.config.js | 4 + apps/customer-ui/public/linear.png | Bin 0 -> 61805 bytes .../src/app/inbox/inbox-client.tsx | 1436 +++++++++++++ apps/customer-ui/src/app/inbox/layout.tsx | 10 - apps/customer-ui/src/app/inbox/page.tsx | 1132 +--------- .../src/app/inbox/settings/page.tsx | 453 +--- .../app/inbox/settings/settings-client.tsx | 1375 +++++++++++++ apps/customer-ui/src/app/inbox/team/page.tsx | 13 + .../src/app/inbox/team/team-client.tsx | 900 ++++++++ apps/customer-ui/src/app/layout.tsx | 7 +- apps/customer-ui/src/app/login/loading.tsx | 9 + .../src/app/login/login-client.tsx | 58 + apps/customer-ui/src/app/login/page.tsx | 64 +- apps/customer-ui/src/app/onboarding/page.tsx | 2 +- apps/customer-ui/src/lib/server-auth.ts | 48 + apps/customer-ui/tsconfig.tsbuildinfo | 1 + docs/README.md | 26 + docs/architecture/integrations.md | 220 ++ docs/architecture/linea.md | 244 +++ docs/architecture/overview.md | 74 + docs/architecture/skills.md | 100 + docs/architecture/tool-uis.md | 111 + docs/guides/configuration.md | 94 + docs/guides/demo.md | 81 + docs/guides/linear-setup.md | 85 + docs/guides/slack-setup.md | 72 + libs/core/auth/src/lib/auth.queue.ts | 1 + libs/core/auth/src/lib/auth.service.ts | 5 + libs/core/common/src/lib/common.module.ts | 3 + libs/core/common/src/lib/db/schema.ts | 44 + .../decorators/current-workspace.decorator.ts | 35 + libs/core/common/src/lib/decorators/index.ts | 1 + .../src/lib/interfaces/auth.interface.ts | 1 + libs/core/common/src/lib/jobs/jobs.module.ts | 29 + libs/core/common/src/lib/models/auth.model.ts | 5 + .../common/src/lib/models/domain.model.ts | 130 +- libs/core/integration/README.md | 7 + libs/core/integration/eslint.config.mjs | 3 + libs/core/integration/jest.config.cts | 10 + libs/core/integration/project.json | 9 + libs/core/integration/src/index.ts | 7 + .../integration/src/lib/github.service.ts | 836 ++++++++ .../integration/src/lib/google.service.ts | 443 ++++ .../src/lib/integration.controller.ts | 660 ++++++ .../integration/src/lib/integration.facade.ts | 51 + .../integration/src/lib/integration.models.ts | 233 +++ .../integration/src/lib/integration.module.ts | 37 + .../src/lib/integration.oauth.service.ts | 643 ++++++ .../src/lib/integration.resolver.ts | 150 ++ .../src/lib/integration.service.ts | 287 +++ .../src/lib/integration.webhook.service.ts | 230 +++ .../integration/src/lib/linear.service.ts | 245 +++ .../integration/src/lib/slack-bolt.service.ts | 121 ++ .../core/integration/src/lib/slack.service.ts | 158 ++ libs/core/integration/tsconfig.json | 23 + libs/core/integration/tsconfig.lib.json | 23 + libs/core/integration/tsconfig.spec.json | 16 + libs/core/linea/src/index.ts | 5 + .../linea/src/lib/assistant.controller.ts | 189 +- libs/core/linea/src/lib/assistant.service.ts | 187 +- .../src/lib/jobs/linea-jobs.constants.ts | 5 + .../src/lib/jobs/linea-jobs.processor.ts | 521 +++++ .../linea/src/lib/jobs/linea-jobs.service.ts | 244 +++ .../linea/src/lib/jobs/linea-jobs.types.ts | 65 + libs/core/linea/src/lib/linea-admin.models.ts | 337 +++ .../linea/src/lib/linea-admin.resolver.ts | 353 ++++ libs/core/linea/src/lib/linea.events.ts | 3 + libs/core/linea/src/lib/linea.facade.ts | 157 +- libs/core/linea/src/lib/linea.module.ts | 38 +- libs/core/linea/src/lib/linea.queue.ts | 717 +++++++ libs/core/linea/src/lib/prompts/agent.ts | 253 +++ libs/core/linea/src/lib/prompts/index.ts | 459 +---- libs/core/linea/src/lib/prompts/subagents.ts | 67 + libs/core/linea/src/lib/prompts/tools.ts | 491 +++++ .../src/lib/services/agent-prompt.service.ts | 172 ++ .../linea/src/lib/services/agent.factory.ts | 87 +- .../github-onboarding-graphs.service.ts | 453 ++++ .../linea/src/lib/services/graphs.factory.ts | 221 +- .../services/heartbeat-settings.service.ts | 221 ++ .../identity-linking-graphs.service.ts | 378 ++++ libs/core/linea/src/lib/services/index.ts | 19 + .../linear-onboarding-graphs.service.ts | 1813 +++++++++++++++++ .../src/lib/services/linear-skills.factory.ts | 759 +++++++ .../lib/services/onboarding-graphs.factory.ts | 1514 +------------- .../linea/src/lib/services/sandbox.service.ts | 239 +++ .../linea/src/lib/services/skills.factory.ts | 132 ++ .../slack-onboarding-graphs.service.ts | 385 ++++ .../src/lib/services/subagents.factory.ts | 186 +- .../src/lib/services/team-insights.service.ts | 756 +++++++ .../linea/src/lib/services/tools.factory.ts | 1362 ++++++++++++- .../src/lib/skills/blocker-detection/skill.ts | 79 + libs/core/linea/src/lib/skills/index.ts | 4 + .../lib/skills/linear-integration/skill.ts | 102 + .../src/lib/skills/project-status/skill.ts | 104 + .../src/lib/skills/team-analysis/skill.ts | 63 + libs/core/linea/src/lib/thread.models.ts | 75 +- libs/core/linea/src/lib/thread.resolver.ts | 133 +- libs/core/linea/src/lib/types/index.ts | 402 +++- .../ui/src/components/assistant-ui/index.ts | 2 +- .../ui/src/components/deployment-section.tsx | 8 +- .../client/ui/src/components/hero-section.tsx | 17 +- .../src/components/how-it-works-section.tsx | 12 +- .../src/components/inbox/inbox-composer.tsx | 70 + .../src/components/inbox/inbox-messages.tsx | 102 + .../ui/src/components/inbox/inbox-thread.tsx | 566 +++++ .../ui/src/components/inbox/linea-chat.tsx | 343 ++++ .../components/inbox/linear-onboarding.tsx | 255 +++ .../src/components/integrations-section.tsx | 25 +- .../client/ui/src/components/navbar.tsx | 6 - .../onboarding/integrations-step.tsx | 92 +- .../ui/src/components/onboarding/otp-step.tsx | 7 +- .../ui/src/components/open-source-section.tsx | 8 +- .../ui/src/components/pricing-section.tsx | 12 +- .../ui/src/components/problem-section.tsx | 20 +- .../providers/LaunchlineRuntimeProvider.tsx | 206 +- .../components/providers/langchain-utils.ts | 67 +- .../components/providers/langraph-runtime.ts | 361 ---- .../ui/src/components/solution-section.tsx | 40 +- .../components/tool-ui/approval/approval.tsx | 224 +- .../src/components/tool-ui/approval/index.ts | 2 + .../src/components/tool-ui/github/github.tsx | 525 +++++ .../src/components/tool-ui/google/google.tsx | 182 ++ .../ui/src/components/tool-ui/google/index.ts | 6 + .../ui/src/components/tool-ui/inbox/inbox.tsx | 63 +- .../ui/src/components/tool-ui/inbox/index.ts | 2 +- .../client/ui/src/components/tool-ui/index.ts | 66 + .../ui/src/components/tool-ui/linear/index.ts | 13 + .../src/components/tool-ui/linear/linear.tsx | 1062 ++++++++++ .../src/components/tool-ui/memories/index.ts | 8 +- .../components/tool-ui/memories/memories.tsx | 282 ++- .../components/tool-ui/sandbox/sandbox.tsx | 126 ++ .../ui/src/components/tool-ui/shared/index.ts | 1 + .../components/tool-ui/shared/markdown.tsx | 28 + .../src/components/tools/linea/LineaTools.tsx | 26 - .../client/ui/src/components/ui/dialog.tsx | 158 ++ .../ui/src/components/value-props-section.tsx | 10 +- .../ui/src/components/vision-section.tsx | 41 +- .../shared/client/ui/src/lib/apollo/client.ts | 6 +- .../ui/src/lib/apollo/operations/threads.ts | 68 + .../client/ui/src/lib/graphql/client.ts | 2 +- libs/shared/client/ui/src/lib/inbox-store.tsx | 703 +------ package.json | 36 +- pnpm-lock.yaml | 1497 ++++++++++++-- tsconfig.base.json | 3 +- 155 files changed, 29253 insertions(+), 5458 deletions(-) create mode 100644 apps/core/migrations/0001_oval_sentinel.sql create mode 100644 apps/core/migrations/0002_small_squadron_sinister.sql create mode 100644 apps/core/migrations/meta/0001_snapshot.json create mode 100644 apps/core/migrations/meta/0002_snapshot.json create mode 100644 apps/core/src/demo-seed.ts create mode 100644 apps/customer-ui/public/linear.png create mode 100644 apps/customer-ui/src/app/inbox/inbox-client.tsx delete mode 100644 apps/customer-ui/src/app/inbox/layout.tsx create mode 100644 apps/customer-ui/src/app/inbox/settings/settings-client.tsx create mode 100644 apps/customer-ui/src/app/inbox/team/page.tsx create mode 100644 apps/customer-ui/src/app/inbox/team/team-client.tsx create mode 100644 apps/customer-ui/src/app/login/loading.tsx create mode 100644 apps/customer-ui/src/app/login/login-client.tsx create mode 100644 apps/customer-ui/src/lib/server-auth.ts create mode 100644 apps/customer-ui/tsconfig.tsbuildinfo create mode 100644 docs/README.md create mode 100644 docs/architecture/integrations.md create mode 100644 docs/architecture/linea.md create mode 100644 docs/architecture/overview.md create mode 100644 docs/architecture/skills.md create mode 100644 docs/architecture/tool-uis.md create mode 100644 docs/guides/configuration.md create mode 100644 docs/guides/demo.md create mode 100644 docs/guides/linear-setup.md create mode 100644 docs/guides/slack-setup.md create mode 100644 libs/core/common/src/lib/decorators/current-workspace.decorator.ts create mode 100644 libs/core/common/src/lib/jobs/jobs.module.ts create mode 100644 libs/core/integration/README.md create mode 100644 libs/core/integration/eslint.config.mjs create mode 100644 libs/core/integration/jest.config.cts create mode 100644 libs/core/integration/project.json create mode 100644 libs/core/integration/src/index.ts create mode 100644 libs/core/integration/src/lib/github.service.ts create mode 100644 libs/core/integration/src/lib/google.service.ts create mode 100644 libs/core/integration/src/lib/integration.controller.ts create mode 100644 libs/core/integration/src/lib/integration.facade.ts create mode 100644 libs/core/integration/src/lib/integration.models.ts create mode 100644 libs/core/integration/src/lib/integration.module.ts create mode 100644 libs/core/integration/src/lib/integration.oauth.service.ts create mode 100644 libs/core/integration/src/lib/integration.resolver.ts create mode 100644 libs/core/integration/src/lib/integration.service.ts create mode 100644 libs/core/integration/src/lib/integration.webhook.service.ts create mode 100644 libs/core/integration/src/lib/linear.service.ts create mode 100644 libs/core/integration/src/lib/slack-bolt.service.ts create mode 100644 libs/core/integration/src/lib/slack.service.ts create mode 100644 libs/core/integration/tsconfig.json create mode 100644 libs/core/integration/tsconfig.lib.json create mode 100644 libs/core/integration/tsconfig.spec.json create mode 100644 libs/core/linea/src/lib/jobs/linea-jobs.constants.ts create mode 100644 libs/core/linea/src/lib/jobs/linea-jobs.processor.ts create mode 100644 libs/core/linea/src/lib/jobs/linea-jobs.service.ts create mode 100644 libs/core/linea/src/lib/jobs/linea-jobs.types.ts create mode 100644 libs/core/linea/src/lib/linea-admin.models.ts create mode 100644 libs/core/linea/src/lib/linea-admin.resolver.ts create mode 100644 libs/core/linea/src/lib/linea.events.ts create mode 100644 libs/core/linea/src/lib/linea.queue.ts create mode 100644 libs/core/linea/src/lib/prompts/agent.ts create mode 100644 libs/core/linea/src/lib/prompts/subagents.ts create mode 100644 libs/core/linea/src/lib/prompts/tools.ts create mode 100644 libs/core/linea/src/lib/services/agent-prompt.service.ts create mode 100644 libs/core/linea/src/lib/services/github-onboarding-graphs.service.ts create mode 100644 libs/core/linea/src/lib/services/heartbeat-settings.service.ts create mode 100644 libs/core/linea/src/lib/services/identity-linking-graphs.service.ts create mode 100644 libs/core/linea/src/lib/services/linear-onboarding-graphs.service.ts create mode 100644 libs/core/linea/src/lib/services/linear-skills.factory.ts create mode 100644 libs/core/linea/src/lib/services/sandbox.service.ts create mode 100644 libs/core/linea/src/lib/services/skills.factory.ts create mode 100644 libs/core/linea/src/lib/services/slack-onboarding-graphs.service.ts create mode 100644 libs/core/linea/src/lib/services/team-insights.service.ts create mode 100644 libs/core/linea/src/lib/skills/blocker-detection/skill.ts create mode 100644 libs/core/linea/src/lib/skills/index.ts create mode 100644 libs/core/linea/src/lib/skills/linear-integration/skill.ts create mode 100644 libs/core/linea/src/lib/skills/project-status/skill.ts create mode 100644 libs/core/linea/src/lib/skills/team-analysis/skill.ts create mode 100644 libs/shared/client/ui/src/components/inbox/inbox-composer.tsx create mode 100644 libs/shared/client/ui/src/components/inbox/inbox-messages.tsx create mode 100644 libs/shared/client/ui/src/components/inbox/inbox-thread.tsx create mode 100644 libs/shared/client/ui/src/components/inbox/linea-chat.tsx create mode 100644 libs/shared/client/ui/src/components/inbox/linear-onboarding.tsx delete mode 100644 libs/shared/client/ui/src/components/providers/langraph-runtime.ts create mode 100644 libs/shared/client/ui/src/components/tool-ui/github/github.tsx create mode 100644 libs/shared/client/ui/src/components/tool-ui/google/google.tsx create mode 100644 libs/shared/client/ui/src/components/tool-ui/google/index.ts create mode 100644 libs/shared/client/ui/src/components/tool-ui/index.ts create mode 100644 libs/shared/client/ui/src/components/tool-ui/linear/index.ts create mode 100644 libs/shared/client/ui/src/components/tool-ui/linear/linear.tsx create mode 100644 libs/shared/client/ui/src/components/tool-ui/sandbox/sandbox.tsx create mode 100644 libs/shared/client/ui/src/components/tool-ui/shared/markdown.tsx delete mode 100644 libs/shared/client/ui/src/components/tools/linea/LineaTools.tsx create mode 100644 libs/shared/client/ui/src/components/ui/dialog.tsx 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 0000000000000000000000000000000000000000..d534500fcab2bb16dbd8dbc04018875480093298 GIT binary patch literal 61805 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 literal 0 HcmV?d00001 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..7b2a1fa --- /dev/null +++ b/apps/customer-ui/src/app/inbox/inbox-client.tsx @@ -0,0 +1,1436 @@ +'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 + } + } +`; + +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'>('priority'); + 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(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 normalizedThreadStatus = + isOptimisticArchived || + (thread.status || '').toLowerCase() === 'archived' + ? 'archived' + : 'regular'; + const fallbackStatus = + normalizedThreadStatus === 'archived' ? 'archived' : 'pending'; + + return { + id: thread.remoteId, + externalId: thread.remoteId, + type: ( + thread.inboxItemType || 'update' + ).toLowerCase() as InboxItemType, + status: thread.inboxStatus?.toLowerCase() || fallbackStatus, + threadStatus: normalizedThreadStatus, + priority: thread.inboxPriority?.toLowerCase() || 'medium', + title: thread.title || 'Untitled', + summary: thread.summary || '', + timestamp: thread.createdAt ? new Date(thread.createdAt) : new Date(), + 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], + ); + + // 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 ? ( + <> + {/* 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..d4510ec --- /dev/null +++ b/apps/customer-ui/src/app/inbox/settings/settings-client.tsx @@ -0,0 +1,1375 @@ +'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( + 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... +

+ ) : ( + <> +